Data Shaping and Transformation — Learn LPAD/CONCAT_WS, AGE/INTERVAL, FILTER Aggregates, and Regular Expressions in Practice
AdvancedTransformation FunctionsLPAD / CONCAT_WSAGE / INTERVALCASE + GREATESTREGEXP_MATCHPostgreSQL5 questions
QUESTION 6
LPAD / RPAD / CONCAT_WS — Generate Report Codes with Zero Padding and Fixed-Width Formatting
LPAD/RPADCONCAT_WSReport Code GenerationNULL-Safe Concatenation
Background

These functions pad strings to a fixed length and concatenate strings safely when NULL values are present.

LPAD('5', 6, '0')                        -- → '000005'    Pad on the left with '0' to six characters
LPAD('ABCDEFGH', 6, '0')                -- → 'ABCDEF'    Truncate the right side when input exceeds the length
RPAD('FOOD', 8, '.')                     -- → 'FOOD....'   Pad on the right with '.' to eight characters
UPPER('tokyo')                            -- → 'TOKYO'      Convert all letters to uppercase
CONCAT_WS('-', '2024', 'TKY', '000005')  -- → '2024-TKY-000005'  Join with a separator
CONCAT_WS('-', '2024', NULL, '000005')   -- → '2024-000005'      Skip NULL ✓
CONCAT('2024', '-', NULL, '-', '000005')   -- → '2024--000005'     Ignore NULL but duplicate separators ✗
The advantage of CONCAT_WS: CONCAT_WS(separator, a, b, c) automatically skips NULL arguments and inserts the separator only between the remaining values. PostgreSQL's regular CONCAT also ignores NULL, but passing separators as separate arguments can produce duplicates such as '2024--000005'. Real-world data often contains NULL values, so prefer CONCAT_WS for separator-delimited concatenation.
Problem

Generate the following columns from the invoices table.
① padded_seq: a six-digit, zero-padded sequence code from the integer seq_no
② invoice_no: a report number in the form {fiscal_year}-{UPPER(branch_code)}-{padded_seq}
③ display_label: customer_name padded on the right with dots to 16 characters.

Table
▸ invoices
invoice_idfiscal_yearbranch_codeseq_nocustomer_name
12024tky5Alpine Cafe
22024osk123Tech Corp
32024tky42Garden Shop
42025ngy1Book World
Expected Output

Expected output (generated report codes):

invoice_idpadded_seqinvoice_nodisplay_label
10000052024-TKY-000005Alpine Cafe.....
20001232024-OSK-000123Tech Corp.......
30000422024-TKY-000042Garden Shop.....
40000012025-NGY-000001Book World......

display_label has a fixed length of 16 characters (Alpine Cafe has 11 characters, so five dots are added).

QUESTION 7
AGE / INTERVAL / NOW() — Calculate Expiration, Days Remaining, and Renewal Dates in Real Time
AGE/INTERVALNOW()Date ArithmeticExpiration Management
Background

These functions calculate intervals relative to the current date and time.

CURRENT_DATE                                     -- → '2024-12-15'  Today's date (DATE)
NOW()                                            -- → '2024-12-15 09:30+09'  Current date and time (TIMESTAMP WITH TIME ZONE)

-- Subtract DATE values → INTEGER (number of days)
'2025-01-14'::DATE - '2024-12-15'::DATE       -- → 30  (integer total days)

-- AGE → INTERVAL (human-readable relative representation)
AGE('2025-11-30'::DATE, '2024-12-15'::DATE)  -- → '11 mons 15 days' (not a total of 350 days!)
EXTRACT(DAY FROM AGE(...))                    -- → 15  ← day component only, not total days

-- Add or subtract INTERVAL to calculate future or past dates
'2025-01-14'::DATE + INTERVAL '1 year'       -- → '2026-01-14 00:00:00'
NOW() - INTERVAL '30 days'                      -- → timestamp 30 days ago
Pitfall — EXTRACT(DAY FROM AGE(...)) is not total days: AGE('2025-11-30', '2024-12-15') returns the INTERVAL '11 mons 15 days'. Applying EXTRACT(DAY FROM ...) returns 15, the day component only, not the total of 350 days. Use expires_at::DATE - CURRENT_DATE when you need total days as an integer.
Problem

Using CURRENT_DATE as the reference, calculate the following from the subscriptions table (the example output uses 2024-12-15).
① days_remaining: days until expiration, negative if already expired
② status: one of 'Expired', 'Expiring Soon' (within 30 days), or 'Active'
③ renewal_at: the next scheduled renewal date (current expiration date + one year) in 'YYYY-MM-DD' format.
Sort the result by days_remaining ascending.

Table
▸ subscriptions
user_idplanstarted_atexpires_at
1basic2024-01-152025-01-14
2pro2023-06-012024-07-31
3premium2024-11-012025-11-30
4basic2024-03-012024-12-31
Expected Output

Expected output (CURRENT_DATE = 2024-12-15, days_remaining ascending):

user_idplandays_remainingstatusrenewal_at
2pro-137Expired2025-07-31
4basic16Expiring Soon2025-12-31
1basic30Expiring Soon2026-01-14
3premium350Active2026-11-30

user_id=2 expired 137 days before 2024-12-15. user_id=1 and 4 expire within 30 days.

QUESTION 8
CASE WHEN + ROUND + GREATEST — Apply Tiered Discounts and a Price Floor in One Query
CASE WHENGREATEST/LEASTTiered DiscountsReuse Expressions with CTE
Background

This pattern combines multi-condition branching, lower and upper bounds, and a CTE (Common Table Expression).

-- Simple CASE form for equality: CASE value WHEN comparison THEN result
CASE customer_rank
  WHEN 'gold'   THEN 0.10   -- When customer_rank = 'gold'
  WHEN 'silver' THEN 0.05
  ELSE               0.00   -- When nothing matches; make ELSE explicit
END

GREATEST(405, 500)      -- → 500   Maximum argument, used as a lower bound
GREATEST(NULL, 500)    -- → 500   NULL is ignored in PostgreSQL
LEAST(12000, 10000)   -- → 10000 Minimum argument, used as an upper bound
Eliminate duplicate expressions with a CTE (WITH clause): Repeating CASE WHEN three times in a SELECT hurts maintainability. Calculate the discount rate once in a CTE, then reference it in the main query to write the expression only once (the DRY principle).
Problem

Calculate prices from the orders table after applying a tiered discount based on customer rank.
Discount rates: platinum=20% / gold=10% / silver=5% / bronze=0%
① discount_rate: the rate for the customer's rank
② discounted: the discounted amount, rounded to an integer with ROUND
③ final_price: the final price clamped to a minimum of 500.
Use a CTE (WITH clause) to avoid repeating the CASE WHEN expression.

Table
▸ orders
order_idcustomer_idcustomer_ranksubtotal
1101platinum15000
2102gold8500
3103silver3200
4104bronze980
5105gold450
Expected Output

Expected output (note final_price for order_id=5):

order_idcustomer_ranksubtotaldiscount_ratediscountedfinal_price
1platinum150000.201200012000
2gold85000.1076507650
3silver32000.0530403040
4bronze9800.00980980
5gold4500.10405500 ← floor applied

order_id=5: subtotal=450 → 10% gold discount → 405 → clamp to the minimum price of 500 → 500.

QUESTION 9
FILTER + STRING_AGG(DISTINCT) — Conditional Aggregation with Deduplication
FILTERSTRING_AGG(DISTINCT)Conditional AggregationDeduplicated Aggregation
Background

This pattern combines FILTER, which applies a condition to an aggregate, with deduplicated string aggregation.

-- FILTER: apply a WHERE-like condition inside an aggregate without changing group size
COUNT(*) FILTER (WHERE status = 'completed')     -- → count completed rows only
SUM(amount) FILTER (WHERE status = 'completed')  -- → sum completed amounts only

-- STRING_AGG(DISTINCT ...): concatenate strings without duplicates
STRING_AGG(DISTINCT product_name, ', ' ORDER BY product_name)
  -- → include each product name once in lexical order
  -- Note: ORDER BY can use only the same expression as the DISTINCT argument

-- Multiply by 100.0 to use NUMERIC rather than integer division
100.0 * COUNT(*) FILTER (...) / COUNT(*)  -- → 66.7 (NUMERIC division)
100   * COUNT(*) FILTER (...) / COUNT(*)  -- → 66   (integer division truncates the fraction)
The key difference between FILTER and WHERE: WHERE removes rows before GROUP BY, changing the group's total row count. FILTER applies its condition while aggregating after grouping, so one group can calculate both the completed total and the total number of rows.
Problem

Aggregate the following values by category from the sales table.
① total_sales: all rows, regardless of status
② completed_count: rows where status = 'completed', using FILTER
③ completed_amount: total amount of completed transactions, using FILTER
④ completion_rate: completion percentage, rounded to one decimal place
⑤ products: a deduplicated, lexically sorted product-name list using STRING_AGG(DISTINCT).
Sort the result by category ascending.

Table
▸ sales
sale_idcategoryproduct_nameamountstatus
1foodCoffee500completed
2foodSandwich800completed
3foodCoffee500cancelled
4techMouse3000completed
5techKeyboard8500completed
6techMouse3000refunded
7booksSQL Guide1500completed
8booksDesign Book2200cancelled
Expected Output

Expected output (category ascending):

categorytotal_salescompleted_countcompleted_amountcompletion_rateproducts
books21150050.0Design Book, SQL Guide
food32130066.7Coffee, Sandwich
tech321150066.7Keyboard, Mouse

DISTINCT removes duplicate products: Coffee occurs twice in food but appears only once.

QUESTION 10
REGEXP_MATCHES / REGEXP_REPLACE — Extract and Transform Data from Structured Log Lines
REGEXP_MATCHESREGEXP_REPLACELog AnalysisCapture Groups
Background

This is an advanced pattern for extracting and reformatting data with regular-expression capture groups ().

-- REGEXP_MATCHES: return matched groups as an array (setof text[])
REGEXP_MATCHES('ERROR 404 /api/users', '(\w+) (\d+) (.*)')
  -- → {'ERROR','404','/api/users'}  each group becomes an array element

-- Extract each group with one-based array indexes
(REGEXP_MATCHES(log, '(\w+) (\d+) (.*)'))[1]   -- → 'ERROR'    first group
(REGEXP_MATCHES(log, '(\w+) (\d+) (.*)'))[2]   -- → '404'      second group

-- REGEXP_REPLACE: reformat by referencing capture groups
REGEXP_REPLACE('20240315', '^(\d{4})(\d{2})(\d{2})$', '\1-\2-\3')
  -- → '2024-03-15'  \1=year, \2=month, \3=day

-- Match a pattern with ~, which returns BOOLEAN
'ERROR 404 /api' ~ '^(ERROR|WARN)'      -- → true  (case-sensitive)
'error 404 /api' ~* '^(error|warn)'     -- → true  (~* is case-insensitive)
Choosing REGEXP_MATCHES or REGEXP_REPLACE: REGEXP_MATCHES retrieves matched text, while REGEXP_REPLACE replaces matched text. Both support capture groups (). REGEXP_MATCHES returns no row for a nonmatch, so use LEFT JOIN, CASE, or the singular REGEXP_MATCH when nonmatching input must be retained.
Problem

The app_logs table stores log messages as unstructured strings. Parse them with regular expressions and generate the following columns.
① log_level: log level (ERROR, WARN, or INFO)
② status_code: numeric HTTP status code
③ endpoint: API endpoint path
④ formatted_date: transform log_date from 'YYYYMMDD' to 'YYYY-MM-DD'
⑤ is_error: true when the log level is ERROR or WARN, using the ~ operator.
Sort the result by log_id ascending.

Table
▸ app_logs
log_idlog_datelog_message
120240315ERROR 500 /api/users
220240315INFO 200 /api/health
320240316WARN 429 /api/orders
420240316ERROR 404 /api/products
520240317INFO 200 /api/users
Expected Output

Expected output (log_id ascending):

log_idlog_levelstatus_codeendpointformatted_dateis_error
1ERROR500/api/users2024-03-15true
2INFO200/api/health2024-03-15false
3WARN429/api/orders2024-03-16true
4ERROR404/api/products2024-03-16true
5INFO200/api/users2024-03-17false