Data Shaping and Transformation — Learn TO_CHAR, SPLIT_PART, Reduced-Tax-Rate Rounding, and NULL-Safe Aggregation in Practice
AdvancedTransformation FunctionsTO_CHAR / SPLIT_PARTROUND × Reduced Tax RateNULL-safe AggregationComposite TransformationPostgreSQL Compatible5 questions
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

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

※ order_month can be used directly as the key of a monthly aggregation query with GROUP BY order_month.

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

Expected output:

idemail_useremail_domainphone_normalized
1aliceexample.com09012345678
2bob.smithgmail.com0356789012
3carolcompany.co.jp0612345678
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

Expected output (the tax rate is determined dynamically with CASE WHEN):

product_idnamepricetax_rateprice_with_taxprice_rank
1Coffee Beans18000.081944standard
2Mug22000.102420standard
3Cake6800.08734budget
4Gift Set54000.105940premium
5Eco Bag9800.101078budget

※ Cake: 680×1.08=734.4 → ROUND=734. price_rank is judged on the tax-exclusive price.

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

Expected output:

project_idtask_countcompleted_counttotal_pointscompletion_rate
A321567
B2080
C113100

※ A: SUM(10,NULL,5)=15 (NULL ignored). Completion rate=ROUND(2::NUMERIC/3*100)=67. Even though B has 0 completed, NULLIF avoids a zero-division error.

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

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

※ FM999,999,999 gives comma separation with no leading space. 6700×1.1=7370 (no fraction). 125000×1.1=137500.