SQL Data Transformation — Applied TO_CHAR and SPLIT_PART

ADVTransformation FunctionsTO_CHAR / SPLIT_PARTROUND × Reduced Tax RateNULL-safe AggregationComposite TransformationPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 1

TO_CHAR / DATE_TRUNC — Convert timestamps into formatted display strings and monthly aggregation keys

TO_CHARDATE_TRUNCDate FormattingAggregation Query
Background

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
Main format characters: 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'.
DATE_TRUNC returns TIMESTAMP: DATE_TRUNC returns a TIMESTAMP, not a DATE. When using it as a monthly aggregation key, the standard practical pattern is to stringify it with TO_CHAR(DATE_TRUNC(...), 'YYYY-MM') or cast it with ::DATE.
Problem

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.

Tables used
▸ orders
order_idamountordered_at (TIMESTAMP)
180002024-05-01 09:15:00
2125002024-05-15 14:30:00
332002024-06-02 10:00:00
467002024-06-18 16:45:00
54502024-06-30 23:59:00
Expected Output
order_idamountordered_at_jpordered_timeorder_month
180002024/05/0109:152024-05
2125002024/05/1514:302024-05
332002024/06/0210:002024-06
467002024/06/1816:452024-06
54502024/06/3023:592024-06
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT order_id, amount, TO_CHAR(ordered_at, 'YYYY/MM/DD') AS ordered_at_jp, TO_CHAR(ordered_at, 'HH24:MI') AS ordered_time, TO_CHAR( DATE_TRUNC('month', ordered_at), 'YYYY-MM' ) AS order_month FROM orders ORDER BY order_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
order_idamountordered_at (TIMESTAMP)
180002024-05-01 09:15:00
2125002024-05-15 14:30:00
332002024-06-02 10:00:00
467002024-06-18 16:45:00
54502024-06-30 23:59:00
5 rows
WHERE TO_CHAR(ordered_at,'YYYY-MM')='2024-05'; WHERE ordered_at >= '2024-05-01' AND ordered_at < '2024-06-01';
LEGEND
Rows read / loaded
① 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?
1 / 3
order_idordered_at
12024-05-01 09:15:00
22024-05-15 14:30:00
32024-06-02 10:00:00
42024-06-18 16:45:00
52024-06-30 23:59:00
5 rows
LEARNING POINTS
Main TO_CHAR format characters: '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'.
DATE_TRUNC + GROUP BY for monthly/weekly aggregation: 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 to pull out day-of-week and hour as numbers: 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' ....
ANTI-PATTERNS
Applying TO_CHAR / DATE_TRUNC to a column in the WHERE clause: 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 >= / <.
Assuming DATE_TRUNC returns a DATE: DATE_TRUNC returns a TIMESTAMP. Unless you cast explicitly as DATE_TRUNC('month', ts)::DATE, a type-mismatch error can occur when joining or filtering against a DATE column.
Field note: designing aggregation granularity with TO_CHAR
Sales dashboards for e-commerce and SaaS often need to switch between daily, weekly, monthly, and yearly views. If you parameterize the granularity in the backend as 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.
QUESTION 2

SPLIT_PART / REGEXP_REPLACE — Decompose email addresses and normalize phone numbers

SPLIT_PARTREGEXP_REPLACEString ExtractionData Cleansing
Background

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)
The 4th argument of REGEXP_REPLACE (flags): '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.
Problem

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.

Tables used
▸ contacts
idemailphone
1alice@example.com090-1234-5678
2bob.smith@gmail.com03-5678-9012
3carol@company.co.jp06-1234-5678
Expected Output
idemail_useremail_domainphone_normalized
1aliceexample.com09012345678
2bob.smithgmail.com0356789012
3carolcompany.co.jp0612345678
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT id, SPLIT_PART(email, '@', 1) AS email_user, SPLIT_PART(email, '@', 2) AS email_domain, REGEXP_REPLACE(phone, '-', '', 'g') AS phone_normalized FROM contacts ORDER BY id;
LEGEND
Rows read / loaded
① 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.
1 / 4
idemailphone
1alice@example.com090-1234-5678
2bob.smith@gmail.com03-5678-9012
3carol@company.co.jp06-1234-5678
3 rows
SUBSTRING(email, 1, POSITION('@' IN email) - 1) SPLIT_PART(email, '@', 1)
LEGEND
Rows read / loaded
① FROM
FROM contactsWhat happens if you try to write the username (the part before @) with POSITION + SUBSTRING?
1 / 3
idemail
1alice@example.com
2bob.smith@gmail.com
3no-at-sign-here
3 rows
LEARNING POINTS
SPLIT_PART is ideal for splitting by a delimiter: It is perfect for getting a specific field from a CSV-style value or a path string ('a/b/c/d'). SPLIT_PART('a/b/c', '/', 3)'c'. Non-existent fields are safely returned as an empty string.
Leverage regex patterns with REGEXP_REPLACE: 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.
PostgreSQL regex operators: ~=pattern match, !~=no match, ~*=case-insensitive match. You can filter Gmail users with WHERE email ~ '@gmail\.com$'.
ANTI-PATTERNS
Forgetting the 'g' flag in REGEXP_REPLACE: Omitting the flag replaces only the first match. '090-1234-5678' becomes '0901234-5678', leaving the latter hyphen. When you need to replace all, always specify 'g'.
Email decomposition with POSITION + SUBSTRING: For malformed data with no @, POSITION returns 0, making it SUBSTRING(s, 1, -1), which errors. SPLIT_PART is simpler and more robust.
Field note: should data cleansing be done in the DB or the app?
Ideally, cleansing such as phone-number normalization and email decomposition should be done on the app side at INSERT time, storing already-normalized values. However, for existing dirty legacy data, transformation in SQL becomes necessary. 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.
QUESTION 3

ROUND / CEIL + CASE WHEN — Tax-included price calculation supporting a reduced tax rate, and price-band classification

ROUND/CEILCASE WHENTax CalculationE-commerce Dev
Background

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)
Beware integer-by-integer division: In PostgreSQL, 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.
You can embed CASE WHEN inside ROUND: As in 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.
Problem

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.

Tables used
▸ products
product_idnamepricecategory
1Coffee Beans1800food
2Mug2200goods
3Cake680food
4Gift Set5400gift
5Eco Bag980goods
Expected Output
product_idnamepricetax_rateprice_with_taxprice_rank
1Coffee Beans18000.081944standard
2Mug22000.102420standard
3Cake6800.08734budget
4Gift Set54000.105940premium
5Eco Bag9800.101078budget
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT product_id, name, price, CASE category WHEN 'food' THEN 0.08 ELSE 0.10 END AS tax_rate, ROUND(price * (1 + CASE category WHEN 'food' THEN 0.08 ELSE 0.10 END)) AS price_with_tax, CASE WHEN price >= 3000 THEN 'premium' WHEN price >= 1000 THEN 'standard' ELSE 'budget' END AS price_rank FROM products ORDER BY product_id;
LEGEND
Rows read / loaded
① 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.
1 / 5
product_idnamepricecategory
1Coffee Beans1800food
2Mug2200goods
3Cake680food
4Gift Set5400gift
5Eco Bag980goods
5 rows
SELECT product_id, price * 1.08 AS raw, ROUND(price * 1.08) AS rounded, CEIL(price * 1.08) AS ceiled, FLOOR(price * 1.08) AS floored FROM products WHERE category = 'food';
LEGEND
Rows read / loaded
① 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.
1 / 2
product_idnamepricetax-incl. (raw value)
1Coffee Beans18001800×1.08=1944.0
3Cake680680×1.08=734.4
6Chocolate150150×1.08=162.0
3 rows (food only)
LEARNING POINTS
Choosing between ROUND / CEIL / FLOOR: In e-commerce, ROUND (round half up) is the most common. CEIL is used for business rules where "fractions round up," such as parking or lodging fees. FLOOR is used for consumer-favoring calculations like "discount by one yen." Always check whether the specification states a rounding rule.
You can inline CASE WHEN inside ROUND: A SQL CASE expression can be used anywhere in the SELECT list. Since you can embed it in a computation as in 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.
The result type of INTEGER × NUMERIC: In PostgreSQL, the result of 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.
ANTI-PATTERNS
Losing precision with integer ÷ integer: In PostgreSQL, 8 / 100 * price becomes 0 * price = 0 (integer ÷ integer = integer). Always write it as 0.08 (a NUMERIC literal) or 8::NUMERIC / 100.
Ordering mistake in searched-CASE WHEN conditions: Writing WHEN price >= 1000 THEN 'standard' first classifies even 5400 as 'standard'. In a searched CASE, always write the higher condition (the stricter one) first.
Field note: the DB's responsibility for tax calculation
Whether to calculate the tax-included price on the DB side or the app side is a design decision, but when "the tax-included amount is needed in many places," such as CSV exports, reports, and aggregations, it is efficient to compute it on the DB side and hold it in a view. If the tax rate may change frequently, holding the rate in a DB master table and referencing it via JOIN lets you accommodate rate changes without rewriting SQL. Hard-coding as 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.
QUESTION 4

COALESCE + NULLIF + GROUP BY — Safely aggregate data mixed with NULLs

GROUP BYCOALESCENULL SafetyZero Division
Background

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 and NULL behavior: AVG also excludes NULL rows from the denominator. Passing (0, NULL, 10) to 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.
The precision issue of INTEGER ÷ INTEGER: In PostgreSQL, 2 / 3 is 0. For calculations that need decimals, such as a completion rate, convert the numerator or denominator to ::NUMERIC before dividing.
Problem

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.

Tables used
▸ tasks
task_idproject_idpointscompleted
1A10true
2ANULLfalse
3A5true
4B8false
5BNULLfalse
6C3true
Expected Output
project_idtask_countcompleted_counttotal_pointscompletion_rate
A321567
B2080
C113100
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT project_id, COUNT(*) AS task_count, SUM(CASE WHEN completed THEN 1 ELSE 0 END) AS completed_count, COALESCE(SUM(points), 0) AS total_points, ROUND( SUM(CASE WHEN completed THEN 1 ELSE 0 END)::NUMERIC / NULLIF(COUNT(*), 0) * 100 ) AS completion_rate FROM tasks GROUP BY project_id ORDER BY project_id;
LEGEND
Rows read / loaded
① 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.
1 / 6
task_idproject_idpointscompleted
1A10true
2ANULLfalse
3A5true
4B8false
5BNULLfalse
6C3true
6 rows
total_points / completed_count total_points / NULLIF(completed_count, 0)
LEGEND
Rows read / loaded
① 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?
1 / 3
project_idtotal_pointscompleted_count
A152
B80
C31
3 groups
LEARNING POINTS
The difference in NULL behavior between SUM and AVG: SUM, AVG, and COUNT(column) all ignore NULL rows. However, when every row in a group is NULL, SUM/AVG return NULL (COUNT returns 0). COALESCE(SUM(...), 0) is effective as a defensive implementation for the all-NULL case.
Two ways to write a conditional aggregation: 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).
The difference between COUNT(*) and COUNT(column): 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.
ANTI-PATTERNS
Dividing without NULLIF: If a group whose denominator could be 0 may exist in the GROUP BY result, always use / NULLIF(denominator, 0). Even if you exclude it in WHERE, it can occur with new data. The habit of writing defensively is important.
The precision issue of INTEGER ÷ INTEGER: The completion rate SUM(...) / COUNT(*) loses decimals in integer arithmetic. 2 / 3 is 0. Convert one side to NUMERIC with ::NUMERIC or * 1.0 before dividing.
Field note: best practices for KPI dashboard queries
Project-management tools and SaaS dashboards need to return KPIs such as "task completion rate, average score, achieved points" in a single query. The practical tip is to "always assume cases where the denominator becomes 0." New users (zero activity), empty projects, empty groups after filtering — these definitely occur in production. The habit of writing zero-division prevention with NULLIF together with NULL replacement with COALESCE prevents production incidents before they happen.
QUESTION 5

Composite transformation — Combine TO_CHAR / LPAD / ROUND / CASE WHEN to bulk-generate invoice labels

Composite TransformationTO_CHARReport GenerationFM Flag
Background

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'
The FM prefix of TO_CHAR: 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.
Always cast to TEXT before passing to LPAD: The first argument of LPAD is TEXT. Since passing an INTEGER directly can error, convert it explicitly with invoice_id::TEXT.
Problem

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.

Tables used
▸ invoices
invoice_idamountissued_atpaid_atstatus
180002024-05-012024-05-10paid
21250002024-05-15NULLpending
332002024-06-02NULLoverdue
4267002024-06-182024-06-25paid
Expected Output
invoice_noamount_with_taxissued_at_jppaid_statuspayment_label
INV-0000018,8002024/05/012024-05-10Paid
INV-000002137,5002024/05/15UnpaidAwaiting Payment
INV-0000033,5202024/06/02UnpaidOverdue
INV-0000427,3702024/06/182024-06-25Paid
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT 'INV-' || LPAD(invoice_id::TEXT, 6, '0') AS invoice_no, TO_CHAR( ROUND(amount * 1.1), 'FM999,999,999' ) AS amount_with_tax, TO_CHAR(issued_at, 'YYYY/MM/DD') AS issued_at_jp, COALESCE( TO_CHAR(paid_at, 'YYYY-MM-DD'), 'Unpaid' ) AS paid_status, CASE status WHEN 'paid' THEN 'Paid' WHEN 'pending' THEN 'Awaiting Payment' WHEN 'overdue' THEN 'Overdue' ELSE 'Unknown' END AS payment_label FROM invoices ORDER BY invoice_id;
LEGEND
Rows read / loaded
① 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.
1 / 6
invoice_idamountissued_atpaid_atstatus
180002024-05-012024-05-10paid
21250002024-05-15NULLpending
332002024-06-02NULLoverdue
4267002024-06-182024-06-25paid
4 rows
SELECT TO_CHAR(ROUND(amount*1.1), '999,999,999') AS no_fm, TO_CHAR(ROUND(amount*1.1), 'FM999,999,999') AS with_fm FROM invoices;
LEGEND
Rows read / loaded
① 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.
1 / 2
invoice_idROUND(amount*1.1)
18800
2137500
33520
427370
4 rows
LEARNING POINTS
The FM flag of TO_CHAR: Adding FM as 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.
The COALESCE(TO_CHAR(date_col, ...), 'fallback text') pattern: TO_CHAR(NULL, 'YYYY-MM-DD') returns NULL. Wrapping it in COALESCE lets you write the display logic "date string if there is a date, otherwise fallback text" in one line. This is a frequent pattern for report display of date columns that contain NULLs.
Match the LPAD zero-padding width to the spec: If the ID may exceed 6 digits in the future, you need to increase the width. In systems expecting large data, either use a generous width like LPAD(id::TEXT, 10, '0'), or consider a design that formats on the app side without LPAD.
ANTI-PATTERNS
Passing an INTEGER directly to LPAD: LPAD(invoice_id, 6, '0') errors in PostgreSQL. Always cast to TEXT with invoice_id::TEXT before passing it.
Forgetting the FM flag of TO_CHAR: 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.
Field note: the pros and cons of shaping reports in SQL
By shaping report data such as invoice numbers, tax-included amounts, and payment status in SQL before the app receives it, you gain the benefit of "keeping the backend business logic thin." Especially for CSV exports and direct report-PDF generation (PostgreSQL + PL/pgSQL), shaping on the SQL side becomes essential. On the other hand, since you must fix the SQL every time the format spec changes, there is also a design where the responsibility for display formatting stays in the app layer and SQL focuses on aggregation and processing. Choose based on your team's size, change frequency, and the framework you use.