Back to Article List

SQL Server string functions: CONCAT, REPLACE, TRIM, SPLIT

SQL Server string functions: CONCAT, REPLACE, TRIM, SPLIT

Updated and tested 5 October 2026

Two strings match or don't match according to the collation on the column. Under a case-insensitive collation, 'Acme' and 'ACME' are the same value. Trailing spaces are ignored in a comparison, so 'Acme ' matches 'Acme'. LEN ignores them too. DATALENGTH counts them.

A non-breaking space is not a space to any of those rules. TRIM leaves it where it is and the join fails anyway. A partner file I loaded last month had 40,000 customer rows and roughly 900 company names carrying an invisible trailing non-breaking space, fine on screen and unmatchable on join. The syntax of the SQL Server string functions is trivial. The behaviour is where the work is. Most of this page is queries.

Everything below runs against the same three tables used across this series, plus a staging table for the import examples.

CREATE TABLE dbo.customers (
    customer_id   int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    company_name  nvarchar(200)  NOT NULL,
    contact_email varchar(255)   NULL,
    country_code  char(2)        NOT NULL,
    created_utc   datetime2(3)   NOT NULL DEFAULT (SYSUTCDATETIME())
);

CREATE TABLE dbo.orders (
    order_id     int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    customer_id  int           NOT NULL REFERENCES dbo.customers (customer_id),
    order_date   datetime2(3)  NOT NULL,
    shipped_date datetime2(3)  NULL,
    status       varchar(20)   NOT NULL,
    total_amount decimal(12,2) NOT NULL
);

CREATE TABLE dbo.order_items (
    order_item_id int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    order_id      int           NOT NULL REFERENCES dbo.orders (order_id),
    sku           varchar(40)   NOT NULL,
    quantity      int           NOT NULL,
    unit_price    decimal(12,2) NOT NULL
);

CREATE TABLE dbo.order_import (
    import_id   int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    raw_line    varchar(400)  NOT NULL,
    sku_list    varchar(400)  NULL,
    company_raw nvarchar(200) NULL
);

Concatenate in SQL Server: the + operator versus CONCAT

Start with the version everyone writes first. It's the one that loses data without raising anything.

SELECT company_name + ' <' + contact_email + '>' AS label
FROM dbo.customers;

For every customer with a contact_email, that returns exactly what you'd expect. For every customer without one, it returns NULL. Not the company name with an empty pair of angle brackets. The entire expression, gone, because NULL propagates through + under the default SET CONCAT_NULL_YIELDS_NULL ON. In a 40,000 row export that's 900 blank cells nobody notices until a mail merge goes out addressed to nothing.

CONCAT, added in SQL Server 2012, converts NULL arguments to an empty string before joining them:

SELECT CONCAT(company_name, ' <', contact_email, '>') AS label
FROM dbo.customers;

Now a missing email gives you Northwind Traders <>. Visibly wrong on screen beats invisibly wrong inside a NULL. CONCAT in SQL Server takes 2 to 254 arguments. It converts every one of them to a string type implicitly. It truncates the result at 8,000 characters unless one input is a max type. Cast one argument to varchar(max) and that ceiling goes away.

CONCAT also fixes type juggling. Try SELECT 'Order ' + order_id FROM dbo.orders. You get Conversion failed when converting the varchar value 'Order ' to data type int. int outranks varchar in the precedence list, so SQL Server tries to turn your text into a number. CONCAT('Order ', order_id) just works.

ISNULL, COALESCE and CASE are the tools for deliberate NULL control, and they compose with CONCAT without any trouble. I use + only when I want NULL to propagate, which is rarely.

CONCAT_WS in SQL Server 2017 and later

CONCAT_WS takes a separator as its first argument, then up to 254 values, and it skips NULLs along with their separators. Building an address line is the case it was written for.

SELECT CONCAT_WS(', ', company_name, contact_email, country_code) AS directory_line
FROM dbo.customers;

A NULL contact_email disappears cleanly. Nothing like Northwind Traders, , GB comes back. Microsoft's CONCAT_WS reference carries the argument limits. It ignores SET CONCAT_NULL_YIELDS_NULL entirely, and if every argument is NULL you get an empty varchar(1) back. New in 2017, which for once means new.

REPLACE and the collation trap

REPLACE in SQL Server takes REPLACE(expression, pattern, replacement) and swaps every occurrence. It obeys the collation of the input, which is the surprising part. No literal byte match is happening.

SELECT REPLACE('SKU-1001-BLUE', '-', '_') AS underscored;   -- SKU_1001_BLUE

-- Under a case-insensitive collation, this replaces:
SELECT REPLACE('Server Farm', 'SERVER', 'Node') AS ci_result;   -- Node Farm

-- Force a case-sensitive comparison:
SELECT REPLACE('Server Farm' COLLATE Latin1_General_CS_AS, 'SERVER', 'Node') AS cs_result;
-- Server Farm

Most SQL Server databases are created with a case-insensitive collation, so a script that strips the literal token Server will happily strip SERVER and server too. That's usually what you want. When it isn't, the COLLATE clause on the first argument is the fix. A _CS_AS or a _BIN2 collation both do the job. _BIN2 is stricter, since it also stops treating accented characters as equivalents.

Three more rules. An empty pattern raises no error and hands the input back unchanged. NULL in any of the three arguments returns NULL for the whole expression. The result truncates at 8,000 bytes unless a max type is in play. On nvarchar that means 4,000 characters. A chain of nested REPLACE calls on a long description column can lose the tail without saying so. Same ceiling as CONCAT, same fix: cast to nvarchar(max) before you nest.

CHARINDEX and PATINDEX

CHARINDEX in SQL Server and PATINDEX both find a position in a string. Both are 1-based. Both return 0 when there's no match, so WHERE CHARINDEX(...) > 0 is the existence test you want.

SELECT contact_email,
       CHARINDEX('@', contact_email) AS at_position,
       RIGHT(contact_email, LEN(contact_email) - CHARINDEX('@', contact_email)) AS email_domain
FROM dbo.customers
WHERE CHARINDEX('@', contact_email) > 0;

CHARINDEX takes an optional third argument, a start position. That is how you find the second occurrence: search once, then search again starting one character past what you found. On a max type it returns bigint and not int, which trips up code that assigns the result straight into an int variable.

PATINDEX is the one to reach for when you don't know the exact text you're looking for. It accepts the LIKE wildcard set, so %, _, a character list in brackets and a negated list with [^...].

-- Position of the first digit
SELECT PATINDEX('%[0-9]%', 'SKU-1001-BLUE') AS first_digit;   -- 5

-- Rows where a supposedly numeric field contains anything else
SELECT import_id, raw_line
FROM dbo.order_import
WHERE PATINDEX('%[^0-9]%', raw_line) > 0;

The negated-class search is the one I use constantly on imported data. It finds the bad rows directly. Nobody has to eyeball 40,000 of them. PATINDEX needs the leading and trailing % to search anywhere in the string, and its NULL handling is asymmetric: a NULL pattern gives you NULL back, a NULL expression raises an error.

Both of these wreck an index seek the moment you put them in a WHERE clause, since the column ends up wrapped in a function. What stays sargable and what doesn't is the subject of the WHERE clause in SQL Server. On a staging table of 40,000 rows I don't care and neither should you.

LEFT, RIGHT and SUBSTRING

LEFT and RIGHT take a count. SUBSTRING takes a start position and, historically, a length.

DECLARE @sku varchar(40) = 'SKU-1001-BLUE';

SELECT LEFT(@sku, 3)          AS prefix,      -- SKU
       RIGHT(@sku, 4)         AS suffix,      -- BLUE
       SUBSTRING(@sku, 5, 4)  AS item_code;   -- 1001

In SQL Server 2025 the length argument became optional, so SELECT SUBSTRING('123abc', 4) returns abc. Microsoft's SUBSTRING reference prints both syntax blocks side by side, which is the quickest way to check what your instance accepts. That form is 2025 and later only. On 2022, 2019 and 2017 the same call fails, because length is required there. The portable version is SUBSTRING(@s, 4, LEN(@s)) or RIGHT(@s, LEN(@s) - 3). An over-long length is clamped and not an error.

I have not run the two-argument form against a production instance. Everything above about it comes off the reference page and a scratch database, so take the syntax as reliable and my experience of it as nil.

A start below 1 doesn't error. SUBSTRING(@sku, -2, 6) starts counting from a position before the string and returns only the characters that fall inside it, so you get three characters back and not six. If you're computing the start from another function's output, check it before you pass it.

The pattern that turns up most in real parsing work is a delimiter you have to locate first:

DECLARE @email varchar(255) = '[email protected]';

SELECT LEFT(@email, CHARINDEX('@', @email) - 1) AS local_part,
       SUBSTRING(@email,
                 CHARINDEX('@', @email) + 1,
                 LEN(@email) - CHARINDEX('@', @email)) AS domain_part;

LEN versus DATALENGTH

LEN returns characters and excludes trailing spaces. DATALENGTH returns bytes and trims nothing. The gap between them is a diagnostic.

SELECT LEN('lumadock   ')        AS len_result,         -- 8
       DATALENGTH('lumadock   ')  AS bytes_varchar,      -- 11
       LEN(N'lumadock')           AS len_unicode,        -- 8
       DATALENGTH(N'lumadock')    AS bytes_nvarchar;     -- 16

nvarchar stores two bytes per character, so on an nvarchar column a clean value satisfies DATALENGTH(col) = LEN(col) * 2. Anything that fails that test has trailing whitespace of some kind, and this is exactly how I found those 900 non-breaking spaces:

SELECT customer_id, company_name,
       LEN(company_name)        AS chars,
       DATALENGTH(company_name) AS bytes
FROM dbo.customers
WHERE DATALENGTH(company_name) <> LEN(company_name) * 2;

LEN counts leading spaces and skips trailing ones. That asymmetry is documented behaviour and not a bug. It also explains why WHERE company_name = 'Acme' matches a value stored as 'Acme '. Trailing-space padding is ignored in comparisons. The row joins fine and your export file looks wrong. A non-breaking space is a different character entirely and gets no such courtesy.

TRIM in SQL Server, and what arrived in which version

Most tutorials get the version story wrong here, so here it is precisely. TRIM in SQL Server is 2017. The characters FROM argument, which strips something other than spaces, shipped with it in 2017 and works on 2019 as well. Only the LEADING, TRAILING and BOTH keywords are new in SQL Server 2022, and they need the database compatibility level set to 160, which is a separate thing from the server version.

-- SQL Server 2017 and later
SELECT TRIM('   LumaDock   ')                          AS spaces_gone;
SELECT TRIM('.,! ' FROM '  ...Order #1001!!  ')        AS punctuation_gone;

-- SQL Server 2022 and later, needs compatibility level 160
SELECT TRIM(TRAILING '.' FROM 'northwind.example.')    AS trailing_dot_gone;

That last one is where people get caught after an in-place upgrade. The server is 2022 or 2025, the database was restored from an older instance and kept its old compatibility level, and TRIM(TRAILING ...) throws a syntax error on a server that supports it perfectly well. Check before you blame the version:

SELECT name, compatibility_level
FROM sys.databases
WHERE name = DB_NAME();

On SQL Server 2016 and earlier there's no TRIM at all. The idiom is LTRIM(RTRIM(company_name)), which handles spaces only. Microsoft's TRIM reference puts the compatibility-level note in an Important box. Neither TRIM nor the old pair touches tabs, carriage returns or non-breaking spaces, which is why the cleanup section below nests REPLACE calls inside the trim.

Your idea deserves better hosting

24/7 support 30-day money-back guaranteeRefund covers the VPS plan on your first order. Cancel anytime
Ciclo de Pagamento

VPS.S1

28.38 RON Save  17 %
23.64 RON Mensalmente
  • 2 vCPU AMD EPYC
  • 2 GB RAMMEMÓRIA
  • 30 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos

VPS.S3

71.01 RON Save  33 %
47.33 RON Mensalmente
  • 4 vCPU AMD EPYC
  • 6 GB RAMMEMÓRIA
  • 70 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos

VPS.S4

94.70 RON Save  20 %
75.75 RON Mensalmente
  • 6 vCPU AMD EPYC
  • 8 GB RAMMEMÓRIA
  • 80 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos

EPYC VPS.P1

42.59 RON Save  22 %
33.11 RON Mensalmente
  • 2 vCPU AMD EPYC
  • 4 GB RAMMEMÓRIA
  • 40 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P2

80.49 RON Save  24 %
61.54 RON Mensalmente
  • 2 vCPU AMD EPYC
  • 8 GB RAMMEMÓRIA
  • 80 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P4

142.07 RON Save  23 %
108.91 RON Mensalmente
  • 4 vCPU AMD EPYC
  • 16 GB RAMMEMÓRIA
  • 160 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P5

189.45 RON Save  25 %
142.08 RON Mensalmente
  • 8 vCPU AMD EPYC
  • 16 GB RAMMEMÓRIA
  • 180 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P6

284.20 RON Save  25 %
213.14 RON Mensalmente
  • 8 vCPU AMD EPYC
  • 32 GB RAMMEMÓRIA
  • 200 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

EPYC VPS.P7

331.57 RON Save  29 %
236.82 RON Mensalmente
  • 16 vCPU AMD EPYC
  • 32 GB RAMMEMÓRIA
  • 240 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

Genoa VPS.G2

118.39 RON Save  20 %
94.70 RON Mensalmente
  • 2 vCPUAMD EPYC Genoa 4ª geração 9xx4 com 3,25 GHz ou similar, na arquitetura Zen 4. AMD EPYC G4
  • 4 GB DDR5MEMÓRIA
  • 50 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

Genoa VPS.G4

213.14 RON Save  22 %
165.76 RON Mensalmente
  • 4 vCPUProcessador AMD EPYC com núcleos vCPU dedicados, em hardware de servidor empresarial. AMD EPYC G4
  • 8 GB DDR5MEMÓRIA
  • 100 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

Genoa VPS.G6

426.32 RON Save  22 %
331.57 RON Mensalmente
  • 8 vCPUProcessador AMD EPYC com núcleos vCPU dedicados, em hardware de servidor empresarial. AMD EPYC G4
  • 16 GB DDR5MEMÓRIA
  • 200 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

Genoa VPS.G7

757.94 RON Save  22 %
592.13 RON Mensalmente
  • 8 vCPUProcessador AMD EPYC com núcleos vCPU dedicados, em hardware de servidor empresarial. AMD EPYC G4
  • 32 GB DDR5MEMÓRIA
  • 250 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos. incluídos
  • Backup automático grátisInclui um espaço de backup que pode configurar para diário, semanal ou mensal.

AMD Ryzen VPS.R1

80.49 RON Save  18 %
66.28 RON Mensalmente
  • 1 CPU dedicado AMD Ryzen 9 7950X com 4,5 GHz ou similar, na arquitetura Zen 4. vCPU
  • 4 GB DDR5MEMÓRIA
  • 50 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6 incluídos O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos.
  • Backup automático incluído

AMD Ryzen VPS.R2

142.07 RON Save  17 %
118.39 RON Mensalmente
  • 2 CPU dedicados AMD Ryzen 9 7950X com 4,5 GHz ou similar, na arquitetura Zen 4. vCPU
  • 8 GB DDR5MEMÓRIA
  • 100 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6 incluídos O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos.
  • Backup automático incluído

AMD Ryzen VPS.R4

521.07 RON Save  18 %
426.32 RON Mensalmente
  • 8 CPU dedicados AMD Ryzen 9 7950X com 4,5 GHz ou similar, na arquitetura Zen 4. vCPU
  • 32 GB DDR5MEMÓRIA
  • 400 GB NVMeDISCO
  • Largura de banda ilimitada
  • IPv4 & IPv6 incluídos O suporte a IPv6 está indisponível de momento em França, Finlândia ou nos Países Baixos.
  • Backup automático incluído

*VAT excluded.

INCLUDED WITH EVERY PLAN

✓ No setup fees ✓1 Gbps network
✓ Full root access on KVM ✓ Free server monitoring ✓ Firewall management ✓24/7 support

STRING_SPLIT: turning a delimited string into rows

STRING_SPLIT is a table-valued function, so it goes in the FROM clause and returns a column called value.

SELECT value AS sku
FROM STRING_SPLIT('SKU-1001,SKU-1002,SKU-1003', ',');

Three constraints decide when you can use it. It arrived in SQL Server 2016. It needs the database compatibility level at 130 or above. Below that the engine can't find the function at all, so you get an invalid object name error on a server that has it. And the separator has to be a single character, which rules out splitting on || or ", " without preprocessing.

Applied to a column, it pairs with CROSS APPLY:

SELECT i.import_id, s.value AS sku
FROM dbo.order_import AS i
CROSS APPLY STRING_SPLIT(i.sku_list, ',') AS s
WHERE s.value <> '';

The correlation mechanics (CROSS APPLY and OUTER APPLY) explain why OUTER APPLY is the one you want when a row's list might be NULL. The empty-string filter above is there because consecutive delimiters produce empty rows, and a trailing comma on the last field produces one every time.

Keeping the order with enable_ordinal

Microsoft's documentation is blunt about ordering: "The output rows might be in any order. The order isn't guaranteed to match the order of the substrings in the input string." In practice you'll get input order most of the time on a small string, which is precisely what makes this dangerous. It holds until the plan changes, then it doesn't, and the bug surfaces on the day the table grows.

SQL Server 2022 added a third argument that fixes it properly:

SELECT value, ordinal
FROM STRING_SPLIT('SKU-1001,SKU-1002,SKU-1003', ',', 1)
ORDER BY ordinal;

Pass 1 and you get an ordinal column with the 1-based position of each element. The STRING_SPLIT reference spells out both the compatibility level requirement and the ordinal argument. That third argument is the only supported way to preserve order. On 2019 or 2016 with order mattering, write your own table-valued function or use OPENJSON, which has always returned a key column carrying the array index.

Splitting a delimited string into columns

Rows are the easy direction. Turning a SQL Server string to columns, where 'SKU-1001-BLUE|4|shipped' has to become three named fields, takes a different approach depending on how many parts there are and how much you trust the data.

For a fixed small number of parts and no dots in the values, PARSENAME is the shortest trick in T-SQL. It was written for four-part object names, it counts from the right, and it doesn't care that you're abusing it:

DECLARE @s varchar(50) = 'SKU-1001-BLUE';

SELECT PARSENAME(REPLACE(@s, '-', '.'), 3) AS part1,   -- SKU
       PARSENAME(REPLACE(@s, '-', '.'), 2) AS part2,   -- 1001
       PARSENAME(REPLACE(@s, '-', '.'), 1) AS part3;   -- BLUE

Four parts maximum, and a stray dot in the data shifts every part along or, once the count passes four, returns NULL for everything. I use it for known-shape SKUs and nothing else.

On SQL Server 2022 or later I'd ship the ordinal column with conditional aggregation. It handles any number of parts and it doesn't break on a value containing a dot.

SELECT i.import_id,
       MAX(CASE WHEN s.ordinal = 1 THEN s.value END) AS sku,
       MAX(CASE WHEN s.ordinal = 2 THEN s.value END) AS quantity,
       MAX(CASE WHEN s.ordinal = 3 THEN s.value END) AS status
FROM dbo.order_import AS i
CROSS APPLY STRING_SPLIT(i.raw_line, '|', 1) AS s
GROUP BY i.import_id;

On an older instance the same result comes out of stacked CHARINDEX and SUBSTRING calls. That gets verbose fast. A CROSS APPLY chain computing each delimiter position once is worth the extra lines, and it stops you recalculating CHARINDEX four times per row.

STRING_AGG: rows back into one string

The reverse operation arrived in SQL Server 2017 and replaced the FOR XML PATH contortion that came before it.

SELECT o.order_id,
       STRING_AGG(oi.sku, ', ') WITHIN GROUP (ORDER BY oi.sku) AS sku_list
FROM dbo.orders AS o
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
GROUP BY o.order_id;

WITHIN GROUP (ORDER BY ...) is optional. I always write it, because without it the concatenation order is undefined in exactly the way STRING_SPLIT's output order is. NULL values are skipped and never become empty entries, so no run of orphan commas.

Watch the return type. With a non-max input the result is varchar(8000) or nvarchar(4000), so aggregating a few hundred SKUs into one cell hits a ceiling you didn't ask for. Cast one input to a max type and the limit lifts:

SELECT o.customer_id,
       STRING_AGG(CAST(oi.sku AS varchar(max)), ', ')
           WITHIN GROUP (ORDER BY oi.sku) AS every_sku
FROM dbo.orders AS o
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
GROUP BY o.customer_id;

SQL Server string functions for case, reversal and padding

UPPER and LOWER do what they say. They are fine. My use of them has stayed inside ASCII and a handful of European collations, so if either misbehaves further out than that you will hear it from someone else first. One note only: on a case-insensitive database they change nothing about how a comparison behaves, so WHERE UPPER(country_code) = 'GB' adds a function call and removes an index seek for no benefit.

REVERSE looks like a novelty until you need the last occurrence of a delimiter, which SQL Server has no direct function for:

DECLARE @sku varchar(40) = 'SKU-1001-BLUE-XL';

SELECT RIGHT(@sku, CHARINDEX('-', REVERSE(@sku)) - 1) AS after_last_hyphen;  -- XL

REPLICATE repeats a string a given number of times.

SQL Server date functions uses FORMAT for the same job on dates. Here it does zero padding, which used to be RIGHT(REPLICATE('0', 6) + CAST(order_id AS varchar(6)), 6) and since SQL Server 2012 is just FORMAT(order_id, 'D6'). The caveat is identical in both places. FORMAT is CLR dependent and nondeterministic, so keep it out of anything indexed and use it freely in report output.

Cleaning imported data

Import cleanup is the bulk of the string work most people do, and it goes in a fixed order: find the damage, fix it in a SELECT, then apply it. Never the other way round.

The characters that cause trouble are the ones you can't see. Carriage return is CHAR(13), line feed CHAR(10), tab CHAR(9) and the non-breaking space is CHAR(160) (or NCHAR(160) for Unicode columns). None of them are removed by TRIM, and all of them survive a copy and paste out of a spreadsheet.

Carriage return and line feed being two separate characters is a teletype inheritance, the carriage physically returning to the left margin and the paper advancing a line, two motors and two control codes. Windows kept both, Unix kept one, and files still move between the two badly sixty years later. Back to the query.

-- Find the rows that need work
SELECT customer_id, company_name
FROM dbo.customers
WHERE company_name <> TRIM(company_name)
   OR company_name LIKE '%' + NCHAR(160) + '%'
   OR company_name LIKE '%' + NCHAR(9)   + '%';

Then the fix, nested inside out: swap the invisible characters for real spaces, collapse the doubles, and trim what's left at the ends.

UPDATE dbo.customers
SET company_name = TRIM(
        REPLACE(
        REPLACE(
        REPLACE(company_name, NCHAR(160), N' '),
                              NCHAR(13),  N''),
                              NCHAR(10),  N''))
WHERE company_name <> TRIM(
        REPLACE(
        REPLACE(
        REPLACE(company_name, NCHAR(160), N' '),
                              NCHAR(13),  N''),
                              NCHAR(10),  N''));

The WHERE clause repeats the whole expression so the statement only touches rows that change. The batching argument runs in full under the UPDATE statement in SQL Server, along with the OUTPUT clause for capturing what you changed. On a large table this keeps the transaction small and the log quiet, and it is a habit worth carrying into any update that runs against production.

Run the DATALENGTH against LEN check again afterwards. If it comes back empty, the import is clean and the joins that were failing start matching.

Questions?

Does SQL Server have an INSTR function?

No, the T-SQL equivalent is CHARINDEX, and it takes its two strings the other way round. MySQL's INSTR('foobarbar', 'bar') becomes CHARINDEX('bar', 'foobarbar') in SQL Server, and both return 4. CHARINDEX accepts an optional third argument for the start position, returns 0 when nothing matches and compares under the input's collation. For a pattern in place of a fixed string, SQL Server 2025 adds REGEXP_INSTR.

Jörg L. Günther
About the author

Jörg L. Günther

Jörg has spent much of his career in enterprise software, working first with database deployments and customer environments, then in technical training and documentation. Since then he has written for companies working in virtualisation, database tooling and cloud infrastructure. The subjects he takes on often have a large operational component: database migrations, high-availability setups, storage configuration and procedures carried out on live systems. Jörg lives near Frankfurt. He plays piano and cycles for transport as much as for recreation. He's also been doing woodworking for several years - he says most of the furniture in his study is his own work.

Deploy your next project in seconds

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.