Data Shaping and Transformation — Learn CAST, COALESCE, CASE, and string/date/numeric functions from the Basics
BasicData TransformationCAST / COALESCECASE WHENString / Date / Numeric FunctionsSTRING_AGG / REGEXPPostgreSQL Compatible5 questions
QUESTION 1
CAST / :: — Convert text data to numeric and date types
CASTType ConversionCleansingCSV Import
Background

CAST(expr AS type) is a function that converts a value to a different data type. It is essential when fixing data that arrives with every column as TEXT—through CSV imports or external integrations—into the correct types. In PostgreSQL you can also use the shorthand notation expr::type.

CAST('42'         AS INTEGER)  -- '42'       → 42         (integer)
CAST('9.99'       AS NUMERIC)  -- '9.99'     → 9.99       (decimal)
CAST('2024-05-01' AS DATE)     -- '2024-05-01'→ date type
'42'::INTEGER                   -- PostgreSQL shorthand (equivalent)
Why type conversion matters: Sorting TEXT with ORDER BY makes '10' < '2' (lexicographic order). Date arithmetic such as '2024-05-01' + 7 also errors out. Proper type conversion enables arithmetic, date operations, and correct sorting.
Problem

Every column in the raw_imports table was imported as TEXT. Convert id to INTEGER, amount to NUMERIC, and registered_at to DATE, and add a tax-included amount at a 10% tax rate (amount_with_tax). Output in ascending order of id.

Tables used
▸ raw_imports (all columns TEXT)
idamountregistered_at
'1''8000''2024-05-01'
'2''12500''2024-05-15'
'3''3200''2024-06-02'
Expected Output

Expected output (type conversion + tax calculation):

idamountregistered_atamount_with_tax
180002024-05-018800
2125002024-05-1513750
332002024-06-023520

amount_with_tax = CAST(amount AS NUMERIC) * 1.1. Multiplying TEXT by 1.1 directly errors out (or relies on implicit casting).

Model Answer
SELECT
  CAST(id            AS INTEGER) AS id,             -- TEXT '1','2','3' → integer
  CAST(amount        AS NUMERIC) AS amount,         -- TEXT → numeric (now arithmetic-capable)
  CAST(registered_at AS DATE)    AS registered_at,  -- TEXT → date type
  CAST(amount        AS NUMERIC) * 1.1
                                  AS amount_with_tax  -- Arithmetic after CAST
FROM   raw_imports
ORDER BY id;  -- Ascending as integers (as TEXT, '10'<'2' — lexicographic order)

/*
  Logical execution order:
  1. FROM raw_imports            → Read all 3 rows as TEXT
  2. SELECT CAST(id AS INTEGER)  → '1','2','3'
  3. ORDER BY id                 → Sort ascending as integers
  */
Explanation (table transitions & key points)
SELECT CAST(id AS INTEGER) AS id, CAST(amount AS NUMERIC) AS amount, CAST(registered_at AS DATE) AS registered_at, CAST(amount AS NUMERIC) * 1.1 AS amount_with_tax FROM raw_imports ORDER BY id;
LEGEND
Rows read / loaded
① FROM — Source data (all TEXT)
FROM raw_importsEvery column imported from the CSV is TEXT. As is, neither numeric nor date operations are possible. Use CAST to convert to the right types.
1 / 4
id (TEXT)amount (TEXT)registered_at (TEXT)
'1''8000''2024-05-01'
'2''12500''2024-05-15'
'3''3200''2024-06-02'
3 rows (all TEXT)
SELECT id, amount FROM raw_imports ORDER BY amount;
LEGEND
Rows read / loaded
① FROM
FROM raw_importsData where every column is TEXT. What happens when we sort amount as is?
1 / 2
idamount (TEXT)
'1''8000'
'2''12500'
'3''3200'
3 rows
LEARNING POINTS
When to use CAST vs. the :: operator: CAST(col AS INTEGER) is standard SQL syntax and works on any DBMS. col::INTEGER is PostgreSQL-specific shorthand that reads well but does not work on MySQL or SQL Server. Choose based on your team's DBMS.
ORDER BY on TEXT produces lexicographic sorting: In ascending TEXT order, '10' < '2' holds (comparison starts from the first character). Always CAST numeric data to NUMERIC/INTEGER before ORDER BY.
CAST can fail: CAST('abc' AS INTEGER) errors out. To return NULL when conversion fails, use PostgreSQL patterns such as CASE WHEN col ~ '^\d+$' THEN col::INTEGER END or TO_NUMBER.
ANTI-PATTERNS
Relying on implicit casts: In PostgreSQL '2024-05-01' + INTERVAL '7 days' may work, but behavior varies by DBMS and type combination. Writing an explicit CAST clarifies intent and improves portability and readability.
Storing and comparing dates as TEXT: WHERE registered_at > '2024-05-01' (TEXT vs. TEXT) becomes a lexicographic comparison. Always store dates as DATE/TIMESTAMP, and use CAST only for conversion at data-ingestion time.
Field note: data-type design also affects performance
Choosing the right types at table-design time directly affects storage efficiency, index performance, and aggregation speed. Simply following the basics—NUMERIC for money, INTEGER/BIGINT for IDs, TIMESTAMP for datetimes, BOOLEAN for flags—prevents many performance problems. If an existing table has the wrong type, you can change it with ALTER TABLE ... ALTER COLUMN ... TYPE, but existing data must be re-validated.
QUESTION 2
COALESCE / NULLIF — Replace NULL with another value, and convert back
COALESCENULLIFNULL-SafeNULL Caution
Background

COALESCE(val1, val2, ...) evaluates its arguments left to right and returns the first non-NULL value. It is the most basic function for setting a substitute value for NULL. NULLIF(val1, val2) is the inverse: it returns NULL when val1 and val2 are equal (converting a specific value to NULL).

COALESCE(NULL, 'Not set')   -- → 'Not set'  (replaces NULL)
COALESCE('Tanaka', 'Not set') -- → 'Tanaka'   (non-NULL kept as is)
COALESCE(NULL, NULL, 0)   -- → 0          (multiple candidates allowed)
NULLIF(0, 0)              -- → NULL       (0=0, so converted to NULL)
NULLIF(42, 0)             -- → 42         (42≠0, so kept as is)
The divide-by-zero guard pattern: Using NULLIF as in total / NULLIF(count, 0) returns NULL for rows where count is 0, preventing divide-by-zero errors. This is a common pattern in aggregation queries.
Problem

The users table has rows with NULL in nickname and score. Using COALESCE, produce (1) a display name (nickname if present, otherwise name) and (2) a score (0 when NULL). Also add a score_nullif column that uses NULLIF to convert a score of 0 back to NULL.

Tables used
▸ users
user_idnamenicknamescore
1Tanaka TaroTanaka85
2Sato HanakoNULL92
3Suzuki IchiroSuzuki0
4Yamada JiroNULLNULL
Expected Output

Expected output:

user_iddisplay_namescorescore_nullif
1Tanaka8585
2Sato Hanako9292
3Suzuki0NULL
4Yamada Jiro0NULL

Row 3: score=0 → NULLIF(0,0)=NULL. Row 4: score=NULL → COALESCE(NULL,0)=0 → NULLIF(0,0)=NULL.

Model Answer
SELECT
  user_id,
  COALESCE(nickname, name) AS display_name,  -- Use name when nickname is NULL
  COALESCE(score, 0)       AS score,         -- Use 0 when score is NULL
  NULLIF(score, 0)         AS score_nullif   -- Convert score=0 to NULL (inverse of COALESCE)
FROM   users
ORDER BY user_id;

/*
  Logical execution order:
  1. FROM users                 → Read all 4 rows
  2. COALESCE(nickname, name)   → Evaluate left to right, return the first non-NULL value
     Tanaka (non-NULL)          → Tanaka
     NULL, Sato Hanako          → Sato Hanako (nickname is NULL, so name is used)
     Suzuki (non-NULL)          → Suzuki
     NULL, Yamada Jiro          → Yamada Jiro (same as above)
  3. COALESCE(score, 0)         → 0 when NULL, otherwise unchanged (0 is not NULL)
  4. NULLIF(score, 0)           → NULL when score=0, otherwise return score unchanged
  5. ORDER BY user_id           → Output in ascending user_id
*/
Explanation (table transitions & key points)
SELECT user_id, COALESCE(nickname, name) AS display_name, COALESCE(score, 0) AS score, NULLIF(score, 0) AS score_nullif FROM users ORDER BY user_id;
LEGEND
Rows read / loaded
① FROM — Source data with NULLs
FROM usersnickname and score contain a mix of NULLs. Aggregating with NULL as is can produce unintended results in SUM/AVG.
1 / 5
user_idnamenicknamescore
1Tanaka TaroTanaka85
2Sato HanakoNULL92
3Suzuki IchiroSuzuki0
4Yamada JiroNULLNULL
4 rows (with NULLs)
SELECT user_id, nickname FROM users WHERE nickname = NULL;
LEGEND
Rows read / loaded
① FROM
FROM usersSome rows have NULL nickname.
1 / 3
user_idnickname
1Tanaka
2NULL
3Suzuki
4NULL
4 rows
LEARNING POINTS
COALESCE can take multiple candidates: You can specify three or more candidates, as in COALESCE(nickname, display_name, name, 'No Name'). The first non-NULL from the left wins. This lets you write a fallback chain naturally.
NULLIF's main use is preventing divide-by-zero: With the pattern SUM(amount) / NULLIF(COUNT(*), 0), a group whose count is 0 returns NULL (preventing a divide-by-zero error). It appears often in aggregation queries.
NULL is the "absence of a value," different from 0 or an empty string: score=0 means "the score is confirmed at 0 points," while score=NULL means "the score has not been entered"—distinct states. Converting 0→NULL with NULLIF lets you treat both as the same "undetermined" state.
ANTI-PATTERNS
Using = to compare with NULL: WHERE nickname = NULL is always FALSE. Always use IS NULL / IS NOT NULL to detect NULL.
Mixing empty strings and NULL via COALESCE(col, ''): Converting NULL to an empty string erases the distinction between "not entered" and "entered as an empty string." Design clearly where NULL checks belong in the app and where COALESCE belongs.
Field note: a NULL-first design philosophy
In practice, designs that "avoid NULL as much as possible" (NOT NULL constraints plus default values) coexist with designs that "allow NULL to carry meaning." A common style is to "allow NULL for optional or not-yet-decided values and convert them with COALESCE at output time." The key is to document the cases where NULL can appear, so that developers writing queries later can place COALESCE in the right spots.
QUESTION 3
CASE WHEN — Relabel and categorize column values by condition
CASE WHENConditional BranchingRelabelingAnalytical Aggregation
Background

The SQL CASE expression branches per row and returns different values. There are two ways to write it.

-- (1) Simple CASE (direct value comparison)
CASE status
  WHEN 'completed' THEN 'Completed'
  WHEN 'pending'   THEN 'Pending'
  ELSE 'Other'   -- Without ELSE, NULL is returned
END

-- (2) Searched CASE (arbitrary conditions)
CASE
  WHEN amount >= 10000 THEN 'high'
  WHEN amount >=  5000 THEN 'medium'  -- Evaluated top to bottom; returns the THEN of the first TRUE
  ELSE                   'low'
END
The key to evaluation order: WHEN clauses are evaluated top to bottom, and the THEN value of the first TRUE condition is returned. Subsequent WHENs are not evaluated. For range conditions, the standard practice is to write the stricter condition (the higher band) first.
Problem

Add a status_label column that converts status (English codes) in the orders table into readable labels, and an amount_rank column that ranks by the size of amount (10000 or more: 'high', 5000 or more: 'medium', otherwise: 'low').

Tables used
▸ orders
order_iduser_idamountstatus
10118000completed
102212500pending
10333200cancelled
10416700completed
1054450pending
Expected Output

Expected output:

order_iduser_idamountstatus_labelamount_rank
10118000Completedmedium
102212500Pendinghigh
10333200Cancelledlow
10416700Completedmedium
1054450Pendinglow
Model Answer
SELECT
  order_id, user_id, amount,
  CASE status                       -- Simple CASE: direct value comparison
    WHEN 'completed' THEN 'Completed'
    WHEN 'pending'   THEN 'Pending'
    WHEN 'cancelled' THEN 'Cancelled'
    ELSE                  'Unknown'      -- Unexpected values become 'Unknown'
  END                    AS status_label,
  CASE                               -- Searched CASE: decide by range conditions
    WHEN amount >= 10000 THEN 'high'
    WHEN amount >=  5000 THEN 'medium'  -- Evaluated only for rows where the WHEN above is FALSE
    ELSE                    'low'
  END                    AS amount_rank
FROM   orders
ORDER BY order_id;

/*
  Logical execution order:
  1. FROM orders                  → Read all 5 rows
  2. CASE status WHEN ... END     → Compare each row's status against WHENs top to bottom and convert
  3. CASE WHEN amount >= ... END  → Evaluate each row's amount top to bottom and rank
  4. ORDER BY order_id            → Output in ascending order_id
  */
Explanation (table transitions & key points)
SELECT order_id, user_id, amount, CASE status WHEN 'completed' THEN 'Completed' WHEN 'pending' THEN 'Pending' WHEN 'cancelled' THEN 'Cancelled' ELSE 'Unknown' END AS status_label, CASE WHEN amount >= 10000 THEN 'high' WHEN amount >= 5000 THEN 'medium' ELSE 'low' END AS amount_rank FROM orders ORDER BY order_id;
LEGEND
Rows read / loaded
① FROM — Read source data
FROM ordersstatus is an English code and amount is a raw integer. For display and analysis, use CASE WHEN to convert them into human-readable forms.
1 / 4
order_iduser_idamountstatus
10118000completed
102212500pending
10333200cancelled
10416700completed
1054450pending
5 rows
SELECT user_id, SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END) AS completed_total, SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS pending_total FROM orders GROUP BY user_id;
LEGEND
Rows read / loaded
① FROM
FROM ordersSuppose we want each status's total amount laid out side by side per user_id.
1 / 4
user_idstatusamount
1completed8000
2pending12500
3cancelled3200
1completed6700
4pending450
5 rows
LEARNING POINTS
CASE works outside SELECT too: You can specify a custom sort order in ORDER BY, as in ORDER BY CASE status WHEN 'completed' THEN 1 WHEN 'pending' THEN 2 ELSE 3 END, and you can also use it in WHERE and GROUP BY clauses.
Applying it to conditional aggregation (pivoting): Using CASE inside an aggregate function, as in SUM(CASE WHEN status='completed' THEN amount ELSE 0 END) AS completed_total, lets you produce a "pivot aggregation" that lays out per-status totals side by side. It is a common pattern in practice.
Omitting ELSE yields NULL: When no WHEN matches and there is no ELSE, NULL is returned. To avoid unintended NULLs, get into the habit of writing ELSE 'Unknown' or ELSE NULL explicitly.
ANTI-PATTERNS
Ordering WHEN conditions wrong: If you write WHEN amount >= 5000 THEN 'medium' first, 12500 also gets classified as 'medium.' In a searched CASE, always write the higher (stricter) condition first.
Confusing it with IIF/DECODE: MySQL's IF() and Oracle's DECODE() do not work in PostgreSQL. Using standard SQL CASE WHEN improves portability.
Field note: managing status codes with CASE WHEN
In practice, a common pattern is to store status codes such as 'completed'/'pending'/'cancelled' in English in the database and convert them to display labels with CASE WHEN at display time. The benefits are code uniqueness, stable sorting, and easy multilingual support. However, when CASE is scattered across many places the cost of change rises, so consolidating the conversion logic into a view or the application layer is also effective.
QUESTION 4
UPPER / LOWER / TRIM / LENGTH — Normalize strings
UPPER/LOWERTRIMString NormalizationCleansing
Background

User input and external data frequently contain mixed case and leading/trailing spaces. Let's cover the main string-normalization functions.

TRIM('  hello  ')       -- → 'hello'    Removes leading/trailing spaces
LTRIM('  hello  ')      -- → 'hello  '  Removes left side only
RTRIM('  hello  ')      -- → '  hello'  Removes right side only
LOWER('Hello@WORLD.COM') -- → 'hello@world.com'
UPPER('hello')          -- → 'HELLO'
LENGTH('hello')         -- → 5  (character count)
They can be nested: As in LOWER(TRIM(email)), functions apply from the inside out. You can write "(1) TRIM to remove whitespace → (2) LOWER to lowercase" in a single line.
Problem

The user_inputs table has email and username values with inconsistent case and leading/trailing spaces. Retrieve the following columns. email: (1) after TRIM (email_trimmed), (2) TRIM + lowercase (email_normalized), (3) character count after TRIM (email_length); username: (1) TRIM (username_trimmed), (2) TRIM + uppercase (username_upper).

Tables used
▸ user_inputs
idemailusername
1' Alice@Example.COM ''alice_dev'
2'BOB@GMAIL.COM'' Bob Smith '
3'carol@test.org ''CAROL'
Expected Output

Expected output:

idemail_trimmedemail_normalizedemail_lengthusername_trimmedusername_upper
1Alice@Example.COMalice@example.com17alice_devALICE_DEV
2BOB@GMAIL.COMbob@gmail.com13Bob SmithBOB SMITH
3carol@test.orgcarol@test.org14CAROLCAROL
Model Answer
SELECT
  id,
  TRIM(email)                AS email_trimmed,    -- Remove leading/trailing spaces
  LOWER(TRIM(email))        AS email_normalized, -- (1) TRIM removes whitespace → (2) LOWER lowercases
  LENGTH(TRIM(email))       AS email_length,    -- Character count after TRIM
  TRIM(username)             AS username_trimmed, -- Remove leading/trailing spaces
  UPPER(TRIM(username))     AS username_upper    -- Uppercase after TRIM
FROM   user_inputs
ORDER BY id;

/*
  Logical execution order:
  1. FROM user_inputs            → Read all 3 rows (leading/trailing spaces and mixed case intact)
  2. TRIM(email)                 → Remove leading/trailing spaces from each row's email
  3. LOWER(TRIM(email))          → Lowercase the TRIM result (evaluated inside out)
  4. LENGTH(TRIM(email))         → Count characters after TRIM
  5. TRIM(username), UPPER(...)  → Convert username the same way
  6. ORDER BY id                 → Output in ascending id
  */
Explanation (table transitions & key points)
SELECT id, TRIM(email) AS email_trimmed, LOWER(TRIM(email)) AS email_normalized, LENGTH(TRIM(email)) AS email_length, TRIM(username) AS username_trimmed, UPPER(TRIM(username)) AS username_upper FROM user_inputs ORDER BY id;
LEGEND
Rows read / loaded
① FROM — Data with spaces and mixed case
FROM user_inputsemail has leading/trailing spaces and mixed case, and username also has leading/trailing spaces and all-caps values. Normalization is needed before storing in the DB or at SELECT time.
1 / 4
idemail (original)username (original)
1' Alice@Example.COM ''alice_dev'
2'BOB@GMAIL.COM'' Bob Smith '
3'carol@test.org ''CAROL'
3 rows (needs cleansing)
SELECT email, LENGTH(email) AS len_raw, LENGTH(TRIM(email)) AS len_trimmed FROM user_inputs;
LEGEND
Rows read / loaded
① FROM
FROM user_inputsData with leading/trailing spaces.
1 / 2
email (original)
' Alice@Example.COM '
'BOB@GMAIL.COM'
'carol@test.org '
3 rows
LEARNING POINTS
ILIKE also works for case-insensitive search: PostgreSQL's ILIKE operator is case-insensitive pattern matching. You can use it as WHERE email ILIKE '%@example.com'. However, indexing LOWER(email) is often faster on large data sets.
TRIM removes spaces from both ends by default: TRIM(BOTH ' ' FROM col) is the full form, but TRIM(col) is equivalent. To remove a specific character, specify it as in TRIM(BOTH 'x' FROM 'xxxhelloxxx') → 'hello'.
The order of function application matters: TRIM(LOWER(email)) and LOWER(TRIM(email)) give the same result, but generally "TRIM to clean up first, then convert" makes the intent clearer.
ANTI-PATTERNS
Computing LENGTH before TRIM: LENGTH(email) returns a count that includes leading/trailing spaces. When you want the count after normalization, always use LENGTH(TRIM(email)).
Comparing in WHERE without normalizing: WHERE email = 'alice@example.com' does not match ' Alice@Example.COM '. It's important to normalize both the input and stored values with LOWER(TRIM(...)) before comparing.
Field note: normalize at INSERT time or SELECT time?
In practice there is a design choice between "normalize at INSERT time and store clean data in the DB" and "normalize at SELECT time with CAST/TRIM/LOWER." Normalizing at INSERT time (CHECK constraints, triggers, app-layer validation) keeps data always clean and queries simple. Normalizing at SELECT time offers the flexibility to handle existing dirty data. For new designs, normalizing at INSERT time and combining SELECT-time conversion for analyzing legacy data is the practical balance.
QUESTION 5
CONCAT / || / LPAD — Concatenate and format strings
CONCAT|| OperatorString ConcatenationDisplay Formatting
Background

There are two ways to concatenate strings: the || operator (SQL standard) and the CONCAT function. The key difference is their behavior toward NULL.

'Tanaka' || ' ' || 'Taro'        -- → 'Tanaka Taro'  (SQL standard)
CONCAT('Tanaka', ' ', 'Taro')     -- → 'Tanaka Taro'  (function version)
CONCAT(NULL, 'Taro')           -- → 'Taro'          (treats NULL as empty string)
NULL || 'Taro'                  -- → NULL            (NULL propagates)
LPAD('7', 4, '0')             -- → '0007'          (pad the left with '0')
RPAD('ABC', 6, '-')           -- → 'ABC---'        (pad the right with '-')
The NULL-behavior difference between || and CONCAT: With the || operator, if either side is NULL the whole result becomes NULL. CONCAT treats NULL as an empty string and continues concatenating. For concatenating columns that might contain NULL, use CONCAT or COALESCE(col, '').
Problem

From the customers table, retrieve full_name using last_name || ' ' || first_name, a CONCAT-based full_name_v2, full_address using pref || city, and a customer_label that includes a zero-padded 4-digit customer number via LPAD (e.g., 'Customer No. 0001').

Tables used
▸ customers
customer_idlast_namefirst_nameprefcity
1TanakaTaroTokyoShibuya
2SatoHanakoOsakaKita
3SuzukiIchiroAichiNaka
Expected Output

Expected output:

customer_idfull_namefull_name_v2full_addresscustomer_label
1Tanaka TaroTanaka TaroTokyoShibuyaCustomer No. 0001
2Sato HanakoSato HanakoOsakaKitaCustomer No. 0002
3Suzuki IchiroSuzuki IchiroAichiNakaCustomer No. 0003
Model Answer
SELECT
  customer_id,
  last_name || ' ' || first_name         AS full_name,     -- Concatenate strings with || (SQL standard)
  CONCAT(last_name, ' ', first_name)    AS full_name_v2,  -- CONCAT function version (NULL-safe)
  pref || city                           AS full_address,  -- Prefecture + city
  'Customer No. ' ||
    LPAD(customer_id::TEXT, 4, '0')   AS customer_label -- Cast to ::TEXT, then zero-pad
FROM   customers
ORDER BY customer_id;

/*
  Logical execution order:
  1. FROM customers                      → Read all 3 rows
  2. last_name || ' ' || first_name      → Concatenate strings in order with ||
  3. CONCAT(last_name, ' ', first_name)  → Function version (treats NULL as empty string)
  4. pref || city                        → Concatenate prefecture and city
  5. LPAD(customer_id::TEXT, 4, '0')     → (1) cast integer to TEXT (2) pad left with '0' to 4 digits
     'Customer No. ' || ...              → Concatenate with the label string
  6. ORDER BY customer_id                → Output in ascending customer_id
*/
Explanation (table transitions & key points)
SELECT customer_id, last_name || ' ' || first_name AS full_name, CONCAT(last_name, ' ', first_name) AS full_name_v2, pref || city AS full_address, 'Customer No. ' || LPAD(customer_id::TEXT, 4, '0') AS customer_label FROM customers ORDER BY customer_id;
LEGEND
Rows read / loaded
① FROM — Read source data
FROM customersThe name is split into last_name/first_name. It needs to be concatenated for app display.
1 / 4
customer_idlast_namefirst_nameprefcity
1TanakaTaroTokyoShibuya
2SatoHanakoOsakaKita
3SuzukiIchiroAichiNaka
3 rows
SELECT first_name || middle_name || last_name AS op_result, CONCAT(first_name, middle_name, last_name) AS fn_result FROM ...
LEGEND
Rows read / loaded
① Virtual data
Virtual data (middle_name is NULL)Suppose there is customer data with no middle name (NULL).
1 / 3
firstmiddlelast
'Taro'NULL'Tanaka'
1 row
LEARNING POINTS
Choosing between || and CONCAT: For columns guaranteed not to contain NULL, || is simple to write. When optional fields such as middle_name are involved, combine with COALESCE as in CONCAT(first_name, COALESCE(' ' || middle_name, ''), ' ', last_name), or use CONCAT.
LPAD is handy for unifying the display format of numbers and codes: LPAD(order_id::TEXT, 8, '0') can generate fixed-length codes like 'ORD00000001'. It appears often in report output, barcode generation, and file-name numbering.
Convert numbers to TEXT before concatenating: Convert integers to TEXT with customer_id::TEXT or CAST(customer_id AS TEXT) before passing them to || or LPAD. Concatenating with mismatched types errors out.
ANTI-PATTERNS
Concatenating NULL-containing columns with ||: 'prefix_' || NULL || '_suffix' becomes NULL. When first_name or middle_name can be NULL, use COALESCE(col, '') or CONCAT.
Trying to use || on a raw integer: 42 || ' items' errors out (PostgreSQL). Type conversion is needed, as in 42::TEXT || ' items' or CONCAT(42, ' items').
Field note: build display strings in the app or the DB?
Building display strings—joining names, formatting addresses, adding labels—can be done in the app (frontend/backend) or in SQL. The benefit of doing it in SQL is that it's easy to reuse across CSV exports, reports, and batch processing. The benefit of doing it in the app is easy multilingual support and UI-format changes. Combining both by preparing pre-formatted columns in a view is a common pattern in practice.