TO_CHAR / DATE_TRUNC — Convert timestamps into formatted display strings and monthly aggregation keys
TO_CHAR(value, format) converts a date or timestamp into an arbitrary string. DATE_TRUNC('unit', timestamp) truncates a timestamp to the start of the specified unit (month, day, hour, etc.). This is the reverse direction of CAST from the basics (date type → string).
TO_CHAR('2024-05-15 14:30:00'::TIMESTAMP, 'YYYY/MM/DD') -- → '2024/05/15' TO_CHAR('2024-05-15 14:30:00'::TIMESTAMP, 'HH24:MI') -- → '14:30' DATE_TRUNC('month', '2024-05-15 14:30:00'::TIMESTAMP) -- → 2024-05-01 00:00:00 DATE_TRUNC('day', '2024-05-15 14:30:00'::TIMESTAMP) -- → 2024-05-15 00:00:00
YYYY=4-digit year, MM=2-digit month, DD=2-digit day, HH24=hour in 24-hour clock, MI=minutes, SS=seconds. DATE_TRUNC accepts units such as 'year'/'month'/'week'/'day'/'hour'.TO_CHAR(DATE_TRUNC(...), 'YYYY-MM') or cast it with ::DATE.The orders table has an ordered_at column of type TIMESTAMP. Add the following three columns and retrieve the data: ①ordered_at_jp: a formatted date ('YYYY/MM/DD'), ②ordered_time: the time portion ('HH24:MI'), ③order_month: a monthly aggregation key ('YYYY-MM' format). Output in ascending order of order_id.
| order_id | amount | ordered_at (TIMESTAMP) |
|---|---|---|
| 1 | 8000 | 2024-05-01 09:15:00 |
| 2 | 12500 | 2024-05-15 14:30:00 |
| 3 | 3200 | 2024-06-02 10:00:00 |
| 4 | 6700 | 2024-06-18 16:45:00 |
| 5 | 450 | 2024-06-30 23:59:00 |
| order_id | amount | ordered_at_jp | ordered_time | order_month |
|---|---|---|---|---|
| 1 | 8000 | 2024/05/01 | 09:15 | 2024-05 |
| 2 | 12500 | 2024/05/15 | 14:30 | 2024-05 |
| 3 | 3200 | 2024/06/02 | 10:00 | 2024-06 |
| 4 | 6700 | 2024/06/18 | 16:45 | 2024-06 |
| 5 | 450 | 2024/06/30 | 23:59 | 2024-06 |
SELECT order_id, amount, TO_CHAR(ordered_at, 'YYYY/MM/DD') AS ordered_at_jp, -- Convert TIMESTAMP to a formatted date string TO_CHAR(ordered_at, 'HH24:MI') AS ordered_time, -- 'HH24'=hour in 24-hour clock, 'MI'=minutes TO_CHAR( DATE_TRUNC('month', ordered_at), -- Truncate to the start of the month (day 1 00:00:00) 'YYYY-MM' -- Convert to a monthly key string (usable in GROUP BY) ) AS order_month FROM orders ORDER BY order_id; /* Execution order (SQL logical evaluation order): 1. FROM orders → Read all 5 rows of TIMESTAMP data 2. TO_CHAR(ordered_at, 'YYYY/MM/DD') → Format the date portion 3. TO_CHAR(ordered_at, 'HH24:MI') → Extract the time portion in 24-hour clock 4. DATE_TRUNC('month', ordered_at) 5. ORDER BY order_id → Output in ascending order of order_id */
LEGEND
① FROM — source data containing timestamps
FROM ordersordered_at is of type TIMESTAMP. We convert it into string formats that are convenient for display and aggregation.| order_id | amount | ordered_at (TIMESTAMP) |
|---|---|---|
| 1 | 8000 | 2024-05-01 09:15:00 |
| 2 | 12500 | 2024-05-15 14:30:00 |
| 3 | 3200 | 2024-06-02 10:00:00 |
| 4 | 6700 | 2024-06-18 16:45:00 |
| 5 | 450 | 2024-06-30 23:59:00 |
LEGEND
① FROM
FROM ordersSuppose we want to filter down to May orders only. What is the problem with applying WHERE to a string produced by TO_CHAR?| order_id | ordered_at |
|---|---|
| 1 | 2024-05-01 09:15:00 |
| 2 | 2024-05-15 14:30:00 |
| 3 | 2024-06-02 10:00:00 |
| 4 | 2024-06-18 16:45:00 |
| 5 | 2024-06-30 23:59:00 |
'YYYY'=4-digit year, 'MM'=2-digit month (zero-padded), 'DD'=2-digit day, 'HH24'=hour in 24-hour clock, 'MI'=minutes, 'SS'=seconds. You can combine them, as in 'YYYY/MM/DD HH24:MI:SS'.GROUP BY DATE_TRUNC('month', ordered_at) gives per-month aggregation, and DATE_TRUNC('week', ...) gives weekly aggregation. This is a frequent pattern in aggregation queries for dashboard charts.EXTRACT(DOW FROM ordered_at) returns integers with Sunday=0, Monday=1 … Saturday=6. To label the weekday, you need something like CASE WHEN EXTRACT(DOW FROM ts) = 0 THEN 'Sun' WHEN 1 THEN 'Mon' ....WHERE TO_CHAR(ordered_at,'YYYY-MM')='2024-05' gives a correct result but cannot use the index. Always write date filters as range comparisons with >= / <.DATE_TRUNC('month', ts)::DATE, a type-mismatch error can occur when joining or filtering against a DATE column.GROUP BY DATE_TRUNC($granularity, ordered_at), a single query can handle all granularities. The display label sent to the frontend is generated with TO_CHAR and used as the key for the chart's x-axis — a common pattern. When a time zone is needed, apply ordered_at AT TIME ZONE 'Asia/Tokyo' first and then DATE_TRUNC.SPLIT_PART / REGEXP_REPLACE — Decompose email addresses and normalize phone numbers
SPLIT_PART(string, delimiter, field_number) splits a string by a delimiter and returns the nth field. The field number is 1-based. REGEXP_REPLACE(string, pattern, replacement, flags) replaces the parts that match the regular expression with other characters.
SPLIT_PART('alice@example.com', '@', 1) -- → 'alice' (1st) SPLIT_PART('alice@example.com', '@', 2) -- → 'example.com' (2nd) SPLIT_PART('a.b.c', '.', 2) -- → 'b' REGEXP_REPLACE('090-1234-5678', '-', '', 'g') -- → '09012345678' ('g'=replace all) REGEXP_REPLACE('090-1234-5678', '-', '') -- → '0901234-5678' (flag omitted=first only)
'g'=global (replace all), 'i'=case-insensitive (ignore case), 'gi'=both. Omitting the flag replaces only the first match. When you need to replace all, such as removing hyphens from a phone number, 'g' is required.Decompose email in the contacts table with SPLIT_PART into email_user (left of @) and email_domain (right of @), and retrieve phone_normalized, in which every hyphen in phone is removed with REGEXP_REPLACE. Output in ascending order of id.
| id | phone | |
|---|---|---|
| 1 | alice@example.com | 090-1234-5678 |
| 2 | bob.smith@gmail.com | 03-5678-9012 |
| 3 | carol@company.co.jp | 06-1234-5678 |
| id | email_user | email_domain | phone_normalized |
|---|---|---|---|
| 1 | alice | example.com | 09012345678 |
| 2 | bob.smith | gmail.com | 0356789012 |
| 3 | carol | company.co.jp | 0612345678 |
SELECT id, SPLIT_PART(email, '@', 1) AS email_user, -- Split by '@' and take the 1st field (username) SPLIT_PART(email, '@', 2) AS email_domain, -- Split by '@' and take the 2nd field (domain) REGEXP_REPLACE(phone, '-', '', 'g') AS phone_normalized -- Replace every '-' with an empty string ('g'=all) FROM contacts ORDER BY id; /* Execution order (SQL logical evaluation order): 1. FROM contacts → Read all 3 rows 2. SPLIT_PART(email, '@', 1) → Split by '@' and return the 1st field (left side) 3. REGEXP_REPLACE(phone, '-', '', 'g') → Replace all parts matching '-' with '' 4. ORDER BY id → Output in ascending order of id */
LEGEND
① FROM — source data with emails and phone numbers
FROM contactsemail has structure on the left and right of '@', and phone is hyphen-separated. We process each with SPLIT_PART and REGEXP_REPLACE respectively.| id | phone | |
|---|---|---|
| 1 | alice@example.com | 090-1234-5678 |
| 2 | bob.smith@gmail.com | 03-5678-9012 |
| 3 | carol@company.co.jp | 06-1234-5678 |
LEGEND
① FROM
FROM contactsWhat happens if you try to write the username (the part before @) with POSITION + SUBSTRING?| id | |
|---|---|
| 1 | alice@example.com |
| 2 | bob.smith@gmail.com |
| 3 | no-at-sign-here |
'a/b/c/d'). SPLIT_PART('a/b/c', '/', 3) → 'c'. Non-existent fields are safely returned as an empty string.REGEXP_REPLACE(phone, '[^0-9]', '', 'g') removes everything that is not a digit. Even a phone number mixing hyphens, parentheses, and spaces can be normalized in a single REGEXP_REPLACE.~=pattern match, !~=no match, ~*=case-insensitive match. You can filter Gmail users with WHERE email ~ '@gmail\.com$'.'090-1234-5678' becomes '0901234-5678', leaving the latter hyphen. When you need to replace all, always specify 'g'.POSITION returns 0, making it SUBSTRING(s, 1, -1), which errors. SPLIT_PART is simpler and more robust.REGEXP_REPLACE / SPLIT_PART are especially powerful in analytical queries and bulk data migrations. For new designs, the flow of app-side validation → store normalized data in the DB → no transformation needed on SELECT minimizes maintenance cost.ROUND / CEIL + CASE WHEN — Tax-included price calculation supporting a reduced tax rate, and price-band classification
ROUND(number, decimal_places) rounds to the specified number of decimal places. Omitting the places rounds to an integer. CEIL(number) rounds up, and FLOOR(number) rounds down.
ROUND(734.4) -- → 734 (round half up, places omitted = integer) ROUND(734.5) -- → 735 (0.5 rounds up) CEIL(734.1) -- → 735 (any fraction always adds 1) FLOOR(734.9) -- → 734 (always drops the fraction) ROUND(9.999, 2) -- → 10.00 (round at the 2nd decimal place)
1 / 3 is 0 (integer ÷ integer = integer). To get decimals, you must convert one side to NUMERIC, as in 1::NUMERIC / 3 or 1.0 / 3. Check the types before using ROUND.ROUND(price * (1 + CASE ... END)), a CASE expression can be inlined into a computation. The tax-included calculation is completed in one line without a subquery.From the products table, for products whose category is 'food' apply a reduced tax rate of 8%, and 10% otherwise, and retrieve the tax-included price (price_with_tax, made an integer with ROUND) together with the price band rank of price (price_rank: 3000 yen or more='premium' / 1000 yen or more='standard' / below that='budget'). Output in ascending order of product_id.
| product_id | name | price | category |
|---|---|---|---|
| 1 | Coffee Beans | 1800 | food |
| 2 | Mug | 2200 | goods |
| 3 | Cake | 680 | food |
| 4 | Gift Set | 5400 | gift |
| 5 | Eco Bag | 980 | goods |
| product_id | name | price | tax_rate | price_with_tax | price_rank |
|---|---|---|---|---|---|
| 1 | Coffee Beans | 1800 | 0.08 | 1944 | standard |
| 2 | Mug | 2200 | 0.10 | 2420 | standard |
| 3 | Cake | 680 | 0.08 | 734 | budget |
| 4 | Gift Set | 5400 | 0.10 | 5940 | premium |
| 5 | Eco Bag | 980 | 0.10 | 1078 | budget |
SELECT product_id, name, price, CASE category -- Simple CASE: determine the tax rate by category WHEN 'food' THEN 0.08 -- Food gets the reduced tax rate of 8% ELSE 0.10 -- Everything else gets the standard 10% END AS tax_rate, ROUND( -- Round the fractional part to an integer price * (1 + CASE category -- Inline the CASE WHEN inside the computation WHEN 'food' THEN 0.08 ELSE 0.10 END) ) AS price_with_tax, CASE -- Searched CASE: judge the band on the tax-exclusive price WHEN price >= 3000 THEN 'premium' -- Write the higher condition first (the rule of searched CASE) WHEN price >= 1000 THEN 'standard' ELSE 'budget' END AS price_rank FROM products ORDER BY product_id; /* Execution order (SQL logical evaluation order): 1. FROM products → Read all 5 rows 2. CASE category → Determine the tax rate by category 3. price * (1 + rate) / ROUND → Compute the tax-included amount and round 4. CASE WHEN price … → Rank by price 5. ORDER BY product_id → Output in ascending order */
LEGEND
① FROM — read the source data
FROM productsThe tax rate applied differs by category. We determine the tax rate dynamically with CASE WHEN, and make the tax-included price an integer with ROUND.| product_id | name | price | category |
|---|---|---|---|
| 1 | Coffee Beans | 1800 | food |
| 2 | Mug | 2200 | goods |
| 3 | Cake | 680 | food |
| 4 | Gift Set | 5400 | gift |
| 5 | Eco Bag | 980 | goods |
LEGEND
① Food examples that produce fractions
food category (tax=8%)Applying an 8% rate to food tends to produce fractions. The amount changes depending on whether you choose ROUND, CEIL, or FLOOR. Always confirm the rounding rule in the specification.| product_id | name | price | tax-incl. (raw value) |
|---|---|---|---|
| 1 | Coffee Beans | 1800 | 1800×1.08=1944.0 |
| 3 | Cake | 680 | 680×1.08=734.4 |
| 6 | Chocolate | 150 | 150×1.08=162.0 |
ROUND(price * (1 + CASE category WHEN 'food' THEN 0.08 ELSE 0.10 END)), the tax-included calculation completes in one line without a subquery.INTEGER * NUMERIC is NUMERIC. Since 0.08 is a NUMERIC literal, 1800 * 0.08 = 144.00 (NUMERIC) and precision is preserved. In contrast, 1800 * 8 / 100 is INTEGER arithmetic, giving 144 (INTEGER) and the decimals vanish.8 / 100 * price becomes 0 * price = 0 (integer ÷ integer = integer). Always write it as 0.08 (a NUMERIC literal) or 8::NUMERIC / 100.WHEN price >= 1000 THEN 'standard' first classifies even 5400 as 'standard'. In a searched CASE, always write the higher condition (the stricter one) first.CASE category WHEN 'food' THEN 0.08 ELSE 0.10 END is effective in the early design stage, but managing tax rates in a master table is worth considering in a later phase.COALESCE + NULLIF + GROUP BY — Safely aggregate data mixed with NULLs
This is an advanced pattern that combines the COALESCE / NULLIF from the basics with GROUP BY aggregation. SUM / AVG automatically ignore NULLs within a group, but if an entire group is NULL, SUM itself returns NULL. Also, NULLIF is especially frequent in aggregation queries as a zero-division-prevention pattern.
SUM(points) -- Sum ignoring NULL rows (returns NULL if all rows are NULL) COALESCE(SUM(points), 0) -- Replace with 0 if SUM is NULL (all rows NULL) SUM(CASE WHEN completed THEN 1 ELSE 0 END) -- Conditional count numerator / NULLIF(denominator, 0) -- Return NULL when the denominator is 0 to prevent zero division
AVG gives (0+10)/2 = 5. If you want to include NULL as 0 in the average, write AVG(COALESCE(col, 0)). Always confirm which is intended.2 / 3 is 0. For calculations that need decimals, such as a completion rate, convert the numerator or denominator to ::NUMERIC before dividing.Group the tasks table by project_id and compute: ①task_count (total number of tasks), ②completed_count (number of completed tasks), ③total_points (sum, treating NULL as 0), ④completion_rate (completion rate %, made an integer with ROUND, zero-division prevented). Output in ascending order of project_id.
| task_id | project_id | points | completed |
|---|---|---|---|
| 1 | A | 10 | true |
| 2 | A | NULL | false |
| 3 | A | 5 | true |
| 4 | B | 8 | false |
| 5 | B | NULL | false |
| 6 | C | 3 | true |
| project_id | task_count | completed_count | total_points | completion_rate |
|---|---|---|---|---|
| A | 3 | 2 | 15 | 67 |
| B | 2 | 0 | 8 | 0 |
| C | 1 | 1 | 3 | 100 |
SELECT project_id, COUNT(*) AS task_count, SUM(CASE WHEN completed THEN 1 ELSE 0 END) AS completed_count, -- Add 1 only for completed tasks COALESCE(SUM(points), 0) AS total_points, -- Ignore NULL rows; 0 if all rows are NULL ROUND( SUM(CASE WHEN completed THEN 1 ELSE 0 END)::NUMERIC -- INTEGER→NUMERIC conversion to secure decimal arithmetic / NULLIF(COUNT(*), 0) * 100 -- Return NULL when COUNT=0 to prevent zero division ) AS completion_rate FROM tasks GROUP BY project_id ORDER BY project_id; /* Execution order (SQL logical evaluation order): 1. FROM tasks → Read all 6 rows 2. GROUP BY project_id 3. COUNT(*) → All row counts within the group (including NULL) 4. SUM(CASE WHEN completed THEN 1 ELSE 0 END) → Add 1 only for true rows 5. COALESCE(SUM(points), 0) → SUM automatically ignores NULL rows 6. SUM(...)::NUMERIC / NULLIF(COUNT(*), 0) * 100 → Compute the completion rate at decimal precision 7. ROUND(...) → Round the fractional part to an integer 8. ORDER BY project_id → Output in ascending order of project_id */
LEGEND
① FROM — source data with NULL and BOOLEAN
FROM taskspoints contains NULLs. completed is a BOOLEAN. Before aggregating with GROUP BY, it is important to understand how NULLs are handled.| task_id | project_id | points | completed |
|---|---|---|---|
| 1 | A | 10 | true |
| 2 | A | NULL | false |
| 3 | A | 5 | true |
| 4 | B | 8 | false |
| 5 | B | NULL | false |
| 6 | C | 3 | true |
LEGEND
① After aggregation — a group with 0 completed exists
Virtual aggregation result after GROUP BYProject B's completed_count is 0. What happens if you try a calculation using this value as the denominator?| project_id | total_points | completed_count |
|---|---|---|
| A | 15 | 2 |
| B | 8 | 0 |
| C | 3 | 1 |
SUM(CASE WHEN completed THEN 1 ELSE 0 END) is SQL-standard and highly portable. In PostgreSQL you can also use the more concise COUNT(*) FILTER (WHERE completed) (PostgreSQL 9.4 and later).COUNT(*) counts all rows including NULLs. COUNT(points) counts only non-NULL rows. Use COUNT(*) for the total number of tasks and COUNT(points) for the number of valid points./ NULLIF(denominator, 0). Even if you exclude it in WHERE, it can occur with new data. The habit of writing defensively is important.SUM(...) / COUNT(*) loses decimals in integer arithmetic. 2 / 3 is 0. Convert one side to NUMERIC with ::NUMERIC or * 1.0 before dividing.Composite transformation — Combine TO_CHAR / LPAD / ROUND / CASE WHEN to bulk-generate invoice labels
In practice you often nest multiple transformation functions. In this problem, we combine the functions learned so far to shape the information needed for an invoice in a single query.
-- Function nesting: applied from the inside out 'INV-' || LPAD(invoice_id::TEXT, 6, '0') -- → 'INV-000001' TO_CHAR(ROUND(amount * 1.1), 'FM999,999,999') -- → '8,800' (FM=strip leading spaces) COALESCE(TO_CHAR(paid_at, 'YYYY-MM-DD'), 'Unpaid') -- → date or 'Unpaid'
TO_CHAR(1234, '999,999') yields a leading space, as in ' 1,234'. Adding FM as 'FM999,999' removes the leading space (FM = Fill Mode). This is important when displaying numbers as strings.invoice_id::TEXT.From the invoices table, generate the following columns: ①invoice_no: 'INV-' + 6-digit zero-padded ID (e.g. 'INV-000001'), ②amount_with_tax: tax-included amount (10%, ROUND, comma-separated format), ③issued_at_jp: the formatted issue date, ④paid_status: the payment date if paid_at is non-NULL, or 'Unpaid' if NULL, ⑤payment_label: a payment-status label with CASE WHEN ('paid'→'Paid', 'pending'→'Awaiting Payment', 'overdue'→'Overdue'). Output in ascending order of invoice_id.
| invoice_id | amount | issued_at | paid_at | status |
|---|---|---|---|---|
| 1 | 8000 | 2024-05-01 | 2024-05-10 | paid |
| 2 | 125000 | 2024-05-15 | NULL | pending |
| 3 | 3200 | 2024-06-02 | NULL | overdue |
| 42 | 6700 | 2024-06-18 | 2024-06-25 | paid |
| invoice_no | amount_with_tax | issued_at_jp | paid_status | payment_label |
|---|---|---|---|---|
| INV-000001 | 8,800 | 2024/05/01 | 2024-05-10 | Paid |
| INV-000002 | 137,500 | 2024/05/15 | Unpaid | Awaiting Payment |
| INV-000003 | 3,520 | 2024/06/02 | Unpaid | Overdue |
| INV-000042 | 7,370 | 2024/06/18 | 2024-06-25 | Paid |
SELECT 'INV-' || LPAD(invoice_id::TEXT, 6, '0') AS invoice_no, -- ①Cast integer to TEXT ②Zero-pad to 6 digits ③Concatenate the prefix TO_CHAR( ROUND(amount * 1.1), -- Round the tax-included amount to an integer 'FM999,999,999' -- FM=strip leading spaces, comma-separated format ) AS amount_with_tax, TO_CHAR(issued_at, 'YYYY/MM/DD') AS issued_at_jp, -- Convert the date to formatted form COALESCE( TO_CHAR(paid_at, 'YYYY-MM-DD'), -- Convert paid_at to a date string if non-NULL 'Unpaid' -- 'Unpaid' if paid_at is NULL ) AS paid_status, CASE status -- Simple CASE: convert English status to a display label WHEN 'paid' THEN 'Paid' WHEN 'pending' THEN 'Awaiting Payment' WHEN 'overdue' THEN 'Overdue' ELSE 'Unknown' -- Always write ELSE to guard against unexpected values END AS payment_label FROM invoices ORDER BY invoice_id; /* Execution order (SQL logical evaluation order): 1. FROM invoices → Read all 4 rows 2. invoice_id::TEXT → Cast integer to TEXT (type-matching the LPAD argument) 3. amount * 1.1 → Compute the tax-included amount (INTEGER×NUMERIC) 4. TO_CHAR(issued_at, 'YYYY/MM/DD') → Convert DATE to a formatted string 5. TO_CHAR(paid_at, 'YYYY-MM-DD') → Convert paid_at to a date string if non-NULL 6. CASE status WHEN 'paid' THEN 'Paid' ... → Convert the status code to a display label 7. ORDER BY invoice_id → Output in ascending order of invoice_id */
LEGEND
① FROM — source invoice data
FROM invoicesinvoice_id is an integer, issued_at is a DATE, paid_at is a DATE containing NULLs, and status is an English code. We shape these into report-display form with several transformation functions.| invoice_id | amount | issued_at | paid_at | status |
|---|---|---|---|---|
| 1 | 8000 | 2024-05-01 | 2024-05-10 | paid |
| 2 | 125000 | 2024-05-15 | NULL | pending |
| 3 | 3200 | 2024-06-02 | NULL | overdue |
| 42 | 6700 | 2024-06-18 | 2024-06-25 | paid |
LEGEND
① Check the numeric format
FROM invoices (integer value of amount × 1.1)Apply comma-separated format with TO_CHAR to the tax-included amount made an integer by ROUND(amount * 1.1). The output changes depending on whether the FM flag is present.| invoice_id | ROUND(amount*1.1) |
|---|---|
| 1 | 8800 |
| 2 | 137500 |
| 3 | 3520 |
| 42 | 7370 |
FM999,999,999 removes the leading space. FM also removes trailing extra zeros. TO_CHAR(1.50, 'FM0.99') → '1.5'. Make it a habit to add FM in reports and CSV output.LPAD(id::TEXT, 10, '0'), or consider a design that formats on the app side without LPAD.LPAD(invoice_id, 6, '0') errors in PostgreSQL. Always cast to TEXT with invoice_id::TEXT before passing it.TO_CHAR(8800, '999,999,999') yields a leading space as ' 8,800'. When output as a string in a CSV export, this space causes string-comparison and display-alignment issues. In comma-separated formats, add FM.