A family of functions for pulling a specific part out of a structured string code (e.g. 'FOOD-001-JP').
SPLIT_PART('FOOD-001-JP', '-', 1) -- → 'FOOD' (the 1st delimited part) SPLIT_PART('FOOD-001-JP', '-', 2) -- → '001' (the 2nd) SPLIT_PART('FOOD-001-JP', '-', 3) -- → 'JP' (the 3rd) LEFT('FOOD-001-JP', 4) -- → 'FOOD' (the leading 4 characters) RIGHT('FOOD-001-JP', 2) -- → 'JP' (the trailing 2 characters) SUBSTRING('FOOD-001-JP' FROM 6 FOR 3) -- → '001' (3 characters from position 6, 1-indexed)
From product_code (format: 'category-item-region') in the products table, use SPLIT_PART to extract category (1st), item_no (2nd), and region (3rd). Also show the alternative implementations using LEFT and SUBSTRING as category_alt and item_no_alt.
| product_id | product_code | price |
|---|---|---|
| 1 | FOOD-001-JP | 980 |
| 2 | ELEC-042-US | 15800 |
| 3 | FOOD-007-JP | 1200 |
| 4 | CLOT-015-EU | 4500 |
Expected output (SPLIT_PART extraction result):
| product_id | product_code | category | item_no | region | category_alt | item_no_alt |
|---|---|---|---|---|---|---|
| 1 | FOOD-001-JP | FOOD | 001 | JP | FOOD | 001 |
| 2 | ELEC-042-US | ELEC | 042 | US | ELEC | 042 |
| 3 | FOOD-007-JP | FOOD | 007 | JP | FOOD | 007 |
| 4 | CLOT-015-EU | CLOT | 015 | EU | CLOT | 015 |
SELECT product_id, product_code, SPLIT_PART(product_code, '-', 1) AS category, -- split on '-', 1st → category SPLIT_PART(product_code, '-', 2) AS item_no, -- 2nd → item number SPLIT_PART(product_code, '-', 3) AS region, -- 3rd → region code LEFT(product_code, 4) AS category_alt, -- leading 4 characters (OK if fixed length) SUBSTRING(product_code FROM 6 FOR 3) AS item_no_alt -- 3 characters from position 6 (1-indexed) FROM products ORDER BY product_id; /* Execution order (SQL's logical evaluation order): 1. FROM products → read all 4 rows 2. SPLIT_PART(code, '-', 1/2/3) → take each part using '-' as the delimiter 3. LEFT(code, 4) → leading 4 characters (F,O,O,D) 4. SUBSTRING(code FROM 6 FOR 3) 5. ORDER BY product_id */
LEGEND
① FROM — source data with structured codes
FROM productsproduct_code has a 'category-item-region' structure delimited by '-', like 'FOOD-001-JP'. We pull out each part with SPLIT_PART.| product_id | product_code | price |
|---|---|---|
| 1 | FOOD-001-JP | 980 |
| 2 | ELEC-042-US | 15800 |
| 3 | FOOD-007-JP | 1200 |
| 4 | CLOT-015-EU | 4500 |
LEGEND
① Hypothetical data
new data with a 4-digit item numberSuppose records slip in where the item number grew from 3 digits like '042' to 4 digits like '0012'.| product_code |
|---|
| 'FOOD-0012-JP' |
SUBSTRING_INDEX(str, delim, n), and in SQL Server use STRING_SPLIT(). Standard SQL has no split-by-delimiter function, so watch out for differences between DBMSs.SUBSTRING(str FROM pos FOR len) treats position 1 as the first character (unlike the 0-indexing of many languages). FROM 6 means the 6th character.POSITION('-' IN product_code) returns the position of the first '-'. When the length is not fixed, combine POSITION with SUBSTRING.LEFT(code, 4) assumes the category is always 4 characters. If a 5-character category is added in the future, everything breaks. Using SPLIT_PART makes you resilient to changes in the number of digits in the format.The main functions used to shape date and time data.
DATE_TRUNC('month', '2024-05-15 14:30:00'::TIMESTAMP) -- → 2024-05-01 00:00:00 (truncate to the first day of the month) DATE_TRUNC('year', ts) -- → first day of the year (2024-01-01 00:00:00) DATE_TRUNC('day', ts) -- → that day at 00:00:00 EXTRACT(MONTH FROM ts) -- → 5 (get the month as a number) EXTRACT(YEAR FROM ts) -- → 2024 TO_CHAR(ts, 'YYYY-MM-DD') -- → '2024-05-15' (convert to any format)
GROUP BY DATE_TRUNC('month', ordered_at) collapses the same month into the same group. With EXTRACT(MONTH FROM ...) alone, May 2024 and May 2025 end up in the same group, so DATE_TRUNC is the safe choice for monthly aggregation.Aggregate the timestamps in the orders table by month. Retrieve month truncated to the first day of the month with DATE_TRUNC, month_label in 'YYYY-MM' format via TO_CHAR, the per-month count order_count, and the total amount total_amount, in ascending order of month.
| order_id | ordered_at | amount |
|---|---|---|
| 101 | 2024-05-03 14:30:00 | 8000 |
| 102 | 2024-05-15 09:15:00 | 12500 |
| 103 | 2024-06-02 18:45:00 | 3200 |
| 104 | 2024-06-20 11:00:00 | 6700 |
Expected output (monthly aggregation):
| month | month_label | order_count | total_amount |
|---|---|---|---|
| 2024-05-01 00:00:00 | 2024-05 | 2 | 20500 |
| 2024-06-01 00:00:00 | 2024-06 | 2 | 9900 |
※ May total: 8000+12500=20500, June total: 3200+6700=9900
SELECT DATE_TRUNC('month', ordered_at) AS month, -- truncate to the first day of the month, 00:00:00 TO_CHAR(DATE_TRUNC('month', ordered_at), 'YYYY-MM') AS month_label, -- display format COUNT(*) AS order_count, -- number of orders per month SUM(amount) AS total_amount -- total amount per month FROM orders GROUP BY DATE_TRUNC('month', ordered_at) -- group by the truncated month ORDER BY month; /* Execution order (SQL's logical evaluation order): 1. FROM orders → read all 4 rows 2. DATE_TRUNC('month', ordered_at) → truncate each row's TIMESTAMP to the month start 3. GROUP BY DATE_TRUNC(...) → group by the same month start 4. COUNT(*), SUM(amount) → aggregate each group 5. TO_CHAR(...) → format the month label as 'YYYY-MM' 6. ORDER BY month → sort in ascending order of month */
LEGEND
① FROM — TIMESTAMP-typed source data
FROM ordersordered_at is a TIMESTAMP such as '2024-05-03 14:30:00'. To aggregate by month, truncate to the first day of the month with DATE_TRUNC, then GROUP BY.| order_id | ordered_at (TIMESTAMP) | amount |
|---|---|---|
| 101 | 2024-05-03 14:30:00 | 8000 |
| 102 | 2024-05-15 09:15:00 | 12500 |
| 103 | 2024-06-02 18:45:00 | 3200 |
| 104 | 2024-06-20 11:00:00 | 6700 |
LEGEND
① Hypothetical data
data spanning yearsSuppose data for May 2024 and May 2025 are mixed together.| ordered_at | amount |
|---|---|
| '2024-05-03' | 8000 |
| '2025-05-15' | 12500 |
DATE_TRUNC('week', ordered_at), and for yearly just change it to 'year'.EXTRACT(MONTH FROM ts) returns the month as a number 1–12. Grouping by that puts May 2024 and May 2025 in the same group. For data spanning years, use DATE_TRUNC.TO_CHAR(ts, 'YYYY-MM-DD HH24:MI:SS').DATE_TRUNC('month', ...).WHERE ordered_at LIKE '2024-05%' (TEXT type) cannot use an index and becomes a full scan. Store as TIMESTAMP and write WHERE ordered_at >= '2024-05-01' AND ordered_at < '2024-06-01'.DATE_TRUNC('month', ordered_at AT TIME ZONE 'Asia/Tokyo'). To correctly aggregate a TIMESTAMP stored in UTC (a 9-hour difference) into a Japan-time "month," time-zone conversion is essential. Ignore the time zone and the first day of the month in Japan time falls into the previous month-end in UTC, causing an aggregation-skew bug.Three kinds of rounding functions for handling the fractional part of a number.
ROUND(524.88) -- → 525 round half up (round up at 0.5 or more) ROUND(90.20) -- → 90 (0.2 is rounded down) ROUND(9.999, 2) -- → 10.00 round to 2 decimal places CEIL(90.20) -- → 91 ceiling; if there is any fraction, always +1 CEIL(90.00) -- → 90 exactly an integer, so no change FLOOR(90.80) -- → 90 floor; always drop the fractional part
From the pre-tax price and tax_rate in the products table, compute the tax-inclusive amount. Attach four columns so they can be compared: with the fraction (tax_raw), ROUND (tax_round), CEIL (tax_ceil), and FLOOR (tax_floor).
| product_id | name | price | tax_rate |
|---|---|---|---|
| 1 | Coffee | 486 | 0.08 |
| 2 | Notebook | 82 | 0.10 |
| 3 | Pen | 95 | 0.10 |
Expected output (comparison of rounding methods):
| product_id | name | price | tax_raw | tax_round | tax_ceil | tax_floor |
|---|---|---|---|---|---|---|
| 1 | Coffee | 486 | 524.88 | 525 | 525 | 524 |
| 2 | Notebook | 82 | 90.20 | 90 | 91 | 90 |
| 3 | Pen | 95 | 104.50 | 105 | 105 | 104 |
※ Notebook (90.20): note the difference CEIL=91 vs ROUND=FLOOR=90. Even a small fraction is always rounded up by CEIL.
SELECT product_id, name, price, tax_rate, price * (1 + tax_rate) AS tax_raw, -- tax-inclusive (with the fraction) ROUND(price * (1 + tax_rate)) AS tax_round, -- round half up (round up at 0.5 or more) CEIL( price * (1 + tax_rate)) AS tax_ceil, -- round up (if there is any fraction, always +1) FLOOR(price * (1 + tax_rate)) AS tax_floor -- round down (always drop the fractional part) FROM products ORDER BY product_id; /* Execution order (SQL's logical evaluation order): 1. FROM products → read all 3 rows 2. price * (1 + tax_rate) → compute the tax-inclusive amount (with the fraction) 3. ROUND(x) → round half up 4. CEIL(x) → round up (if there is any fraction, +1) 5. FLOOR(x) → round down (drop the fractional part) 6. ORDER BY product_id → output in ascending order of product_id */
LEGEND
① FROM — pre-tax price and tax rate
FROM productsprice is pre-tax and tax_rate is 0.08 (8%) or 0.10 (10%). Computing the tax-inclusive amount produces a fractional part after the decimal point.| product_id | name | price | tax_rate |
|---|---|---|---|
| 1 | Coffee | 486 | 0.08 |
| 2 | Notebook | 82 | 0.1 |
| 3 | Pen | 95 | 0.1 |
LEGEND
① FROM
FROM productsThe original price data.| name | price |
|---|---|
| Coffee | 486 |
| Notebook | 82 |
| Pen | 95 |
ROUND(9.875, 2) → 9.88, the second argument specifies the number of decimal places. You can also round the hundreds place and above with a negative number, as in ROUND(1234.5, -2) → 1200.0.1 + 0.2 becomes 0.30000000000000004 in FLOAT is famous. Always use the NUMERIC or DECIMAL type for monetary calculations.FLOAT type internally uses a binary floating-point representation, so even a tax rate like 0.08 cannot be represented exactly. For amounts and tax rates, always use a fixed-point type such as NUMERIC(10,2).FLOOR(price * 1.10) (rounding down) is widely adopted. What matters is explicitly deciding at system-design time "at what granularity and in which direction to handle the fraction," and leaving it in the code as a comment.STRING_AGG(column, delimiter) is combined with GROUP BY to concatenate the string values within a group into a single string. It is equivalent to MySQL's GROUP_CONCAT.
STRING_AGG(product_name, ', ') -- → 'Coffee, Cake, Tea' (order is nondeterministic) STRING_AGG(product_name, ', ' ORDER BY product_name) -- → 'Cake, Coffee, Tea' (order specified by ORDER BY) ARRAY_AGG(product_name ORDER BY product_name) -- → ARRAY['Cake','Coffee','Tea'] (aggregated as an array)
ORDER BY column inside STRING_AGG lets you control the concatenation order within a group. Omit ORDER BY and the order becomes nondeterministic (it may change on every run).From the order_items table, for each order retrieve products, the product names aggregated comma-separated (in alphabetical order of product_name), and the total quantity total_qty. Output in ascending order of order_id.
| order_id | product_name | quantity |
|---|---|---|
| 101 | Coffee | 2 |
| 101 | Sandwich | 1 |
| 102 | Coffee | 1 |
| 102 | Cake | 1 |
| 102 | Tea | 2 |
| 103 | Sandwich | 3 |
Expected output (products aggregated per order):
| order_id | products | total_qty |
|---|---|---|
| 101 | Coffee, Sandwich | 3 |
| 102 | Cake, Coffee, Tea | 4 |
| 103 | Sandwich | 3 |
※ Because ORDER BY product_name is specified, the values are sorted alphabetically.
SELECT order_id, STRING_AGG(product_name, ', ' ORDER BY product_name) AS products, -- concatenate alphabetically (ORDER BY makes the order deterministic) SUM(quantity) AS total_qty -- total quantity within the group FROM order_items GROUP BY order_id -- group by order_id ORDER BY order_id; -- output the result in ascending order of order_id /* Execution order (SQL's logical evaluation order): 1. FROM order_items → read all 6 rows 2. GROUP BY order_id → group by order_id 3. STRING_AGG(...ORDER BY ...) → within each group, order product_name alphabetically and join with ', ' 4. SUM(quantity) → total quantity of each group 5. ORDER BY order_id → output in ascending order of order_id */
LEGEND
① FROM — source data with multiple product rows
FROM order_itemsA single order_id has multiple product rows. With STRING_AGG we aggregate the product names into one row per order_id.| order_id | product_name | quantity |
|---|---|---|
| 101 | Coffee | 2 |
| 101 | Sandwich | 1 |
| 102 | Coffee | 1 |
| 102 | Cake | 1 |
| 102 | Tea | 2 |
| 103 | Sandwich | 3 |
LEGEND
① FROM
FROM order_itemsData with multiple rows for the same order_id.| order_id | product_name |
|---|---|
| 101 | Coffee |
| 101 | Sandwich |
| 102 | Coffee |
| 102 | Cake |
| 102 | Tea |
| 103 | Sandwich |
ARRAY_AGG(product_name ORDER BY product_name) returns a string array ARRAY['Cake','Coffee','Tea']. It is handy for uses such as converting to JSON and returning it as an API response.GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ', '). The syntax differs but the functionality is equivalent.LEFT(STRING_AGG(...), 500).COALESCE(product_name, '(none)').json_agg(row_to_json(items)), you can even generate a complex nested structure with SQL alone.Two kinds of functions that replace a specific pattern in a string with another string.
REPLACE('03-1234-5678', '-', '') -- → '0312345678' replace all occurrences of the fixed string '-' with '' REGEXP_REPLACE('(090) 9876-5432', '[^0-9]', '', 'g') -- → '09098765432' regex: remove all non-digit characters ('g'=global) REGEXP_REPLACE('hello world', '\s+', '_', 'g') -- → 'hello_world' replace whitespace with '_'
The contacts table has phone numbers in inconsistent formats. Retrieve ① replace_hyphen, which removes only hyphens with REPLACE, and ② regexp_digits_only, which removes all non-digit characters with REGEXP_REPLACE, and observe the difference between the two results.
| contact_id | phone |
|---|---|
| 1 | 03-1234-5678 |
| 2 | (090) 9876-5432 |
| 3 | 0120.000.111 |
Expected output (note that rows 2 and 3 differ):
| contact_id | original | replace_hyphen | regexp_digits_only |
|---|---|---|---|
| 1 | 03-1234-5678 | 0312345678 | 0312345678 |
| 2 | (090) 9876-5432 | (090) 98765432 | 09098765432 |
| 3 | 0120.000.111 | 0120.000.111 | 0120000111 |
※ Row 2: REPLACE removes only hyphens → parentheses and spaces remain. REGEXP_REPLACE removes all non-digits. Row 3: REPLACE cannot remove the dots, but REGEXP_REPLACE can.
SELECT contact_id, phone AS original, -- the original phone number REPLACE(phone, '-', '') AS replace_hyphen, -- remove only '-' (fixed string) REGEXP_REPLACE(phone, '[^0-9]', '', 'g') AS regexp_digits_only -- remove all non-digits FROM contacts ORDER BY contact_id; /* Execution order (SQL's logical evaluation order): 1. FROM contacts → read all 3 rows 2. REPLACE(phone, '-', '') → replace every '-' in each row with '' (empty string) 3. REGEXP_REPLACE(phone,'[^0-9]','','g') 4. ORDER BY contact_id */
LEGEND
① FROM — phone numbers in inconsistent formats
FROM contactsThe phone numbers are mixed in different formats: '03-1234-5678', '(090) 9876-5432', and '0120.000.111'. We normalize them into a uniform digit string.| contact_id | phone (original) |
|---|---|
| 1 | 03-1234-5678 |
| 2 | (090) 9876-5432 |
| 3 | 0120.000.111 |
LEGEND
① FROM
digits-only phone numberSuppose there is a 10-digit phone number stored without hyphens.| phone_raw |
|---|
| '0312345678' |
[^0-9] is "any character other than 0-9," \d is "a digit," \s+ is "one or more whitespace characters," and ^\d{11}$ is "an 11-digit numeric string (from start to end)." Use REGEXP_REPLACE to replace, regexp_matches to extract, and the ~ operator for pattern matching.REGEXP_REPLACE('0312345678', '^(\d{2})(\d{4})(\d{4})$', '\1-\2-\3') → '03-1234-5678', you can reformat using grouping (parentheses).REPLACE(REPLACE(REPLACE(phone,'-',''),'(',''),')','') ... makes the code cumbersome. Handle it in one shot with REGEXP_REPLACE.CHECK (phone ~ '^\d{10,11}$'), or auto-normalizing with a trigger or a GENERATED COLUMN. REGEXP_REPLACE is extremely handy for one-off cleaning of legacy data or for conversion in batch processing.