Concatenate strings
Short answer: || is the ANSI standard concatenation operator (Postgres / DuckDB / SQLite / Oracle / Snowflake / Redshift / BigQuery). CONCAT(a, b, c) is a function-form alternative supported almost everywhere, and the only option on MySQL (where || is logical OR by default) and SQL Server (older versions). For inserting a separator between values, use CONCAT_WS(separator, a, b, c). Watch nulls: a || NULL returns NULL on most engines (Oracle is the exception, treating NULL as an empty string); CONCAT skips nulls on Postgres and SQL Server but returns NULL on MySQL and BigQuery.
Canonical SQL #
-- ANSI || operator (Postgres / DuckDB / SQLite / Oracle / Snowflake / Redshift).
SELECT first_name || ' ' || last_name AS full_name FROM users;
-- CONCAT function (works on every major engine).
SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM users;
-- CONCAT_WS — concat with separator, skips NULL inputs.
SELECT CONCAT_WS(', ', street, city, state, zip) AS address FROM addresses;
-- + operator on SQL Server.
SELECT first_name + ' ' + last_name AS full_name FROM users;Example #
Common patterns #
-- Build a contact label; fall back gracefully on NULL pieces.
SELECT CONCAT_WS(' ', name, email) AS contact FROM users;
-- Or with COALESCE for explicit fallback.
SELECT name || ' <' || COALESCE(email, 'no email') || '>' AS contact FROM users;
-- Concatenate a number into a string (engines auto-cast some types, not others).
SELECT 'order-' || id::TEXT FROM orders; -- Postgres / DuckDB
SELECT CONCAT('order-', CAST(id AS VARCHAR)) FROM orders; -- portable
-- Build a comma-separated list across rows — this is a different operation.
-- See: concatenate rows into a string (STRING_AGG / LISTAGG / GROUP_CONCAT).Dialect comparison #
| Engine | Syntax | Gotcha |
|---|---|---|
| PostgreSQL / DuckDB | a || b | any NULL operand makes the result NULL; CONCAT() skips NULLs |
| MySQL | CONCAT(a, b) | || is logical OR unless PIPES_AS_CONCAT; CONCAT returns NULL on any NULL arg |
| SQL Server | CONCAT(a, b) | + propagates NULL and throws on numeric operands; CONCAT treats NULL as '' |
| BigQuery | CONCAT(a, b) | returns NULL on any NULL argument |
| Snowflake | a || b | both || and CONCAT propagate NULL |
| Oracle | a || b | NULL is treated as empty string; does not propagate |
| SQLite | a || b | any NULL operand makes the result NULL |
Dialect notes #
- Postgres / DuckDB / SQLite:
||is concatenation. AnyNULLoperand makes the whole expressionNULL.CONCAT(where present) is a "null-tolerant" alternative — Postgres'CONCATskips nulls. - Oracle:
||is concatenation, butNULLis treated as an empty string. It does not propagate, unlike every other||engine. - MySQL:
||is logical OR by default, so'a' || 'b'returns1, not'ab'. SetPIPES_AS_CONCATSQL mode to make it concatenation. UseCONCAT(a, b, c)for portability; MySQL'sCONCATreturnsNULLif any argument isNULL.CONCAT_WSskipsNULLarguments. - SQL Server:
+is the legacy concat operator (and the cause of countless type-conversion errors when one side is numeric).CONCAT(2012+) is null-tolerant — treatsNULLas empty string.CONCAT_WS(2017+). - Snowflake:
||andCONCATboth work; both propagateNULL(like MySQL'sCONCAT, unlike SQL Server's).CONCAT_WSskips nulls. - BigQuery: supports both
||andCONCAT;CONCATreturnsNULLif any argument isNULL. To join values with a separator, useARRAY_TO_STRING(['a', 'b'], separator)— note BigQuery array literals use[...], notARRAY[...]. - Redshift:
||andCONCAT(a, b)(two-argument only) work; both propagateNULL.
The single most common bug: a NULL in the middle of an || expression returns NULL for the whole row. first_name || ' ' || last_name returns NULL when either name is missing — not "Ana ", not "Costa", not "(unknown) Costa," but NULL. Either reach for CONCAT_WS (skips nulls cleanly), wrap each side in COALESCE(col, ''), or accept that the row should be NULL and document the choice. This bug shows up most often in dashboards where a "name" column suddenly displays as blank for the few users with a missing surname.