How CONCAT_WS() Works in DuckDB

In DuckDB, the CONCAT_WS() function provides an efficient way to join strings with a specified separator. CONCAT_WS() stands for “concatenate with separator”, and many RDBMSs have such a function.

CONCAT_WS() is particularly useful when you need to combine multiple fields or values with a consistent delimiter.

Let’s explore its features and practical applications.

Syntax

The syntax goes like this:

CONCAT_WS(separator, string, ...)

So we provide the separator as the first argument, and then follow this up with all the strings we want to concatenate.

Sample Data

We’ll use the following data for the examples on this page:

-- Create a sample table
CREATE TABLE products (
    id INTEGER,
    category VARCHAR,
    brand VARCHAR,
    model VARCHAR,
    color VARCHAR,
    tags VARCHAR[]
);

-- Insert sample data
INSERT INTO products VALUES
    (1, 'Electronics', 'TechCo', 'Laptop-X1', 'Silver', ARRAY['gaming', 'premium', 'lightweight']),
    (2, 'Accessories', 'GearPro', 'Mouse-M2', NULL, ARRAY['wireless', 'ergonomic']),
    (3, 'Electronics', NULL, 'Tablet-T3', 'Black', ARRAY['budget', 'compact']),
    (4, 'Peripherals', 'ViewTech', 'Monitor-24', 'Black', NULL),
    (5, NULL, 'AudioMax', 'Headset-H1', 'White', ARRAY['bluetooth', 'noise-canceling']);

This data includes NULLs in places that normally shouldn’t contain NULLs, but they will be useful in some of our examples to highlight how CONCAT_WS() handles them.

Basic Usage

Here are a couple of examples that demonstrate the basic usage of the CONCAT_WS() function:

SELECT CONCAT_WS(
    ' - ',
    category,
    brand,
    model
) as product_name
FROM products;

Result:

+-------------------------------------+
| product_name |
+-------------------------------------+
| Electronics - TechCo - Laptop-X1 |
| Accessories - GearPro - Mouse-M2 |
| Electronics - Tablet-T3 |
| Peripherals - ViewTech - Monitor-24 |
| AudioMax - Headset-H1 |
+-------------------------------------+

In this case I specified a hyphen as the separator.

Here are some other separators:

SELECT 
    CONCAT_WS(', ', category, brand, model) as comma_separated,
    CONCAT_WS(' | ', category, brand, model) as pipe_separated,
    CONCAT_WS('/', category, brand, model) as path_style
FROM products;

Result:

+-----------------------------------+-------------------------------------+---------------------------------+
| comma_separated | pipe_separated | path_style |
+-----------------------------------+-------------------------------------+---------------------------------+
| Electronics, TechCo, Laptop-X1 | Electronics | TechCo | Laptop-X1 | Electronics/TechCo/Laptop-X1 |
| Accessories, GearPro, Mouse-M2 | Accessories | GearPro | Mouse-M2 | Accessories/GearPro/Mouse-M2 |
| Electronics, Tablet-T3 | Electronics | Tablet-T3 | Electronics/Tablet-T3 |
| Peripherals, ViewTech, Monitor-24 | Peripherals | ViewTech | Monitor-24 | Peripherals/ViewTech/Monitor-24 |
| AudioMax, Headset-H1 | AudioMax | Headset-H1 | AudioMax/Headset-H1 |
+-----------------------------------+-------------------------------------+---------------------------------+

NULL Handling

CONCAT_WS() skips NULL values:

SELECT 
    -- Original values
    category,
    brand,
    model,
    -- CONCAT_WS result
    CONCAT_WS(' - ', category, brand, model) as result
FROM products
WHERE brand IS NULL OR category IS NULL;

Result:

+-------------+----------+------------+-------------------------+
| category | brand | model | result |
+-------------+----------+------------+-------------------------+
| Electronics | null | Tablet-T3 | Electronics - Tablet-T3 |
| null | AudioMax | Headset-H1 | AudioMax - Headset-H1 |
+-------------+----------+------------+-------------------------+

Empty String Handling

This example compares empty strings with NULL values when they’re passed as strings:

SELECT 
    CONCAT_WS('|', 'A', '', 'B') as empty_string_result,
    CONCAT_WS('|', 'A', NULL, 'B') as null_result,
    CONCAT_WS('|', 'A', '', NULL, 'B') as mixed_result;

Result:

+---------------------+-------------+--------------+
| empty_string_result | null_result | mixed_result |
+---------------------+-------------+--------------+
| A||B | A|B | A||B |
+---------------------+-------------+--------------+

We can see that the empty string causes the separator to be used, whereas the NULL value doesn’t.

Examples of Use Cases

Here are some possible scenarios where the CONCAT_WS() function could come in handy.

Building Hierarchical Paths

We can use CONCAT_WS() to create file-system like paths:

SELECT CONCAT_WS(
    '/',
    'products',
    LOWER(category),
    LOWER(brand),
    LOWER(model)
) as product_path
FROM products
WHERE category IS NOT NULL;

Result:

+------------------------------------------+
| product_path |
+------------------------------------------+
| products/electronics/techco/laptop-x1 |
| products/accessories/gearpro/mouse-m2 |
| products/electronics/tablet-t3 |
| products/peripherals/viewtech/monitor-24 |
+------------------------------------------+

Creating CSV-Style Output

SELECT CONCAT_WS(
    ',',
    id::VARCHAR,
    COALESCE(category, ''),
    COALESCE(brand, ''),
    COALESCE(model, ''),
    COALESCE(color, '')
) as csv_row
FROM products;

Result:

+-----------------------------------------+
| csv_row |
+-----------------------------------------+
| 1,Electronics,TechCo,Laptop-X1,Silver |
| 2,Accessories,GearPro,Mouse-M2, |
| 3,Electronics,,Tablet-T3,Black |
| 4,Peripherals,ViewTech,Monitor-24,Black |
| 5,,AudioMax,Headset-H1,White |
+-----------------------------------------+

Formatting Display Names

We can use CONCAT_WS() to create readable product names:

SELECT 
    CONCAT_WS(
        ' ',
        brand,
        model,
        CONCAT_WS(
            '/',
            category,
            color
        )
    ) as display_name
FROM products;

Result:

+---------------------------------------+
| display_name |
+---------------------------------------+
| TechCo Laptop-X1 Electronics/Silver |
| GearPro Mouse-M2 Accessories |
| Tablet-T3 Electronics/Black |
| ViewTech Monitor-24 Peripherals/Black |
| AudioMax Headset-H1 White |
+---------------------------------------+

Array Element Concatenation

SELECT 
    model,
    CONCAT_WS(', ', ARRAY_TO_STRING(tags, ', ')) as tag_list
FROM products
WHERE tags IS NOT NULL;

Result:

+------------+------------------------------+
| model | tag_list |
+------------+------------------------------+
| Laptop-X1 | gaming, premium, lightweight |
| Mouse-M2 | wireless, ergonomic |
| Tablet-T3 | budget, compact |
| Headset-H1 | bluetooth, noise-canceling |
+------------+------------------------------+

Dynamic SQL Generation

This example uses CONCAT_WS() to help build a SQL WHERE clause:

SELECT CONCAT_WS(
    ' AND ',
    'category IS NOT NULL',
    'brand IS NOT NULL',
    'color IS NOT NULL'
) as where_clause;

Result:

+------------------------------------------------------------------+
| where_clause |
+------------------------------------------------------------------+
| category IS NOT NULL AND brand IS NOT NULL AND color IS NOT NULL |
+------------------------------------------------------------------+

Custom Formatting with Conditions

Here’s one that uses CASE WHEN statements with the CONCAT_WS() function to return a different result depending on the outcome of the conditions:

SELECT 
    CONCAT_WS(
        ' - ',
        CASE WHEN category IS NOT NULL THEN UPPER(category) END,
        brand,
        CASE WHEN color IS NOT NULL THEN CONCAT('(', color, ')') END
    ) as formatted_name
FROM products;

Result:

+----------------------------------+
| formatted_name |
+----------------------------------+
| ELECTRONICS - TechCo - (Silver) |
| ACCESSORIES - GearPro |
| ELECTRONICS - (Black) |
| PERIPHERALS - ViewTech - (Black) |
| AudioMax - (White) |
+----------------------------------+

CONCAT_WS() vs Alternatives

As with many things in SQL, we can often achieve the same or similar results with different approaches. Here are two alternative methods that we can use to get a similar result to CONCAT_WS().

CONCAT_WS() vs CONCAT()

DuckDB also has a CONCAT() function, which can be used to concatenate multiple values. The main difference is that it doesn’t accept a separator as its first argument. Therefore, we need to include the separator in each position that we want it to appear:

SELECT 
    -- Using CONCAT_WS
    CONCAT_WS(' - ', category, brand, model) as concat_ws_version,

    -- Using CONCAT
    CONCAT(category, ' - ', brand, ' - ', model) as concat_version
FROM products;

Result:

+-------------------------------------+-------------------------------------+
| concat_ws_version | concat_version |
+-------------------------------------+-------------------------------------+
| Electronics - TechCo - Laptop-X1 | Electronics - TechCo - Laptop-X1 |
| Accessories - GearPro - Mouse-M2 | Accessories - GearPro - Mouse-M2 |
| Electronics - Tablet-T3 | Electronics - - Tablet-T3 |
| Peripherals - ViewTech - Monitor-24 | Peripherals - ViewTech - Monitor-24 |
| AudioMax - Headset-H1 | - AudioMax - Headset-H1 |
+-------------------------------------+-------------------------------------+

So, while both functions allow us to get a similar result, the CONCAT() function required us to specify the separator twice. If we were concatenating more values, then we’d need to specify the separator even more.

Also, you’ll notice that the CONCAT() version sometimes results in the separator appearing in places it probably shouldn’t. For example, we get two separators side by side in the third row. And the fifth row starts with a separator. This is due to NULL values. The CONCAT_WS() function is smart enough to know not to output the separator when it encounters a NULL value.

For these reasons, CONCAT_WS() is usually a better option than CONCAT() when we need to perform concatenation with a separator.

CONCAT_WS() vs Concatenation Operator (||)

The string concatenation operator (||) is even more verbose than the CONCAT() function:

SELECT 
    -- Using CONCAT_WS
    CONCAT_WS(', ', category, brand, model) as concat_ws_version,

    -- Using string concatenation operator
    category || ', ' || brand || ', ' || model as operator_version
FROM products;

Result:

+-----------------------------------+-----------------------------------+
| concat_ws_version | operator_version |
+-----------------------------------+-----------------------------------+
| Electronics, TechCo, Laptop-X1 | Electronics, TechCo, Laptop-X1 |
| Accessories, GearPro, Mouse-M2 | Accessories, GearPro, Mouse-M2 |
| Electronics, Tablet-T3 | null |
| Peripherals, ViewTech, Monitor-24 | Peripherals, ViewTech, Monitor-24 |
| AudioMax, Headset-H1 | null |
+-----------------------------------+-----------------------------------+

The concatenation operator behaves differently to both CONCAT() and CONCAT_WS() when it comes to NULL values. The concatenation operator simply outputs NULL if any of the strings are NULL.

This makes the CONCAT_WS() function the better option if you want to handle NULL values in a more intelligent way.

Empty Separator

Passing an empty string as the separator results in no separator being used in the output:

SELECT 
    CONCAT_WS('', category, brand, model) as no_separator
FROM products;

Result:

+-------------------------------+
| no_separator |
+-------------------------------+
| ElectronicsTechCoLaptop-X1 |
| AccessoriesGearProMouse-M2 |
| ElectronicsTablet-T3 |
| PeripheralsViewTechMonitor-24 |
| AudioMaxHeadset-H1 |
+-------------------------------+

This has the same effect as using the CONCAT() function without any separators.

NULL Separator

Passing NULL as the separator results in NULL being returned:

SELECT 
    CONCAT_WS(NULL, category, brand, model) as null_separator
FROM products;

Result:

+----------------+
| null_separator |
+----------------+
| null |
| null |
| null |
| null |
| null |
+----------------+