Data Shaping and Transformation — Learn CAST, COALESCE, CASE, and string/date/numeric functions from the Basics
BasicData TransformationCAST / COALESCECASE WHENString / Date / Numeric FunctionsSTRING_AGG / REGEXPPostgreSQL Compatible5 questions
QUESTION 6
SPLIT_PART / LEFT / SUBSTRING — Extract substrings from a structured code
SPLIT_PARTSUBSTRINGString ExtractionCode Parsing
Background

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)
SPLIT_PART vs LEFT/SUBSTRING: SPLIT_PART is intuitive and easy to maintain when the delimiter is fixed. LEFT/SUBSTRING suit a fixed-length format. If the number of delimiters changes, SUBSTRING breaks, whereas SPLIT_PART stays stable because it addresses parts by position number.
Problem

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.

Tables used
▸ products
product_idproduct_codeprice
1FOOD-001-JP980
2ELEC-042-US15800
3FOOD-007-JP1200
4CLOT-015-EU4500
Expected Output

Expected output (SPLIT_PART extraction result):

product_idproduct_codecategoryitem_noregioncategory_altitem_no_alt
1FOOD-001-JPFOOD001JPFOOD001
2ELEC-042-USELEC042USELEC042
3FOOD-007-JPFOOD007JPFOOD007
4CLOT-015-EUCLOT015EUCLOT015
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT product_id, product_code, SPLIT_PART(product_code, '-', 1) AS category, SPLIT_PART(product_code, '-', 2) AS item_no, SPLIT_PART(product_code, '-', 3) AS region, LEFT(product_code, 4) AS category_alt, SUBSTRING(product_code FROM 6 FOR 3) AS item_no_alt FROM products ORDER BY product_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
product_idproduct_codeprice
1FOOD-001-JP980
2ELEC-042-US15800
3FOOD-007-JP1200
4CLOT-015-EU4500
4 rows
SELECT product_code, SPLIT_PART(product_code, '-', 2) AS split_res, SUBSTRING(product_code FROM 6 FOR 3) AS sub_res FROM ...
LEGEND
Rows read / loaded
① 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'.
1 / 3
product_code
'FOOD-0012-JP'
1 row
LEARNING POINTS
SPLIT_PART is PostgreSQL-specific: in MySQL use 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 positions are 1-indexed by standard: in SQL, 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.
Use POSITION to find a delimiter dynamically: POSITION('-' IN product_code) returns the position of the first '-'. When the length is not fixed, combine POSITION with SUBSTRING.
ANTI-PATTERNS
Fixed-position SUBSTRING is fragile against format changes: 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.
Forcing SQL to parse unstructured data: parsing JSON arrays or CSV strings with regular expressions in SQL is complex and hurts performance. Normalize the data into a proper table structure at design time, or use a JSONB column.
Field note: designing structured codes and normalizing the DB
Composite codes like 'FOOD-001-JP' are convenient during operations, but a normalized DB design would properly hold category / item_no / region in separate columns. SPLIT_PART is useful for parsing legacy codes in an existing system with SQL, but for new designs we recommend holding each attribute in its own independent column so that these transformation queries become unnecessary.
QUESTION 7
DATE_TRUNC / EXTRACT / TO_CHAR — aggregate, convert, and format dates and times
DATE_TRUNCTO_CHARMonthly AggregationDate Manipulation
Background

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)
Key point for monthly aggregation: 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.
Problem

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.

Tables used
▸ orders
order_idordered_atamount
1012024-05-03 14:30:008000
1022024-05-15 09:15:0012500
1032024-06-02 18:45:003200
1042024-06-20 11:00:006700
Expected Output

Expected output (monthly aggregation):

monthmonth_labelorder_counttotal_amount
2024-05-01 00:00:002024-05220500
2024-06-01 00:00:002024-0629900

※ May total: 8000+12500=20500, June total: 3200+6700=9900

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT DATE_TRUNC('month', ordered_at) AS month, TO_CHAR(DATE_TRUNC('month', ordered_at), 'YYYY-MM') AS month_label, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY DATE_TRUNC('month', ordered_at) ORDER BY month;
LEGEND
Rows read / loaded
① 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.
1 / 4
order_idordered_at (TIMESTAMP)amount
1012024-05-03 14:30:008000
1022024-05-15 09:15:0012500
1032024-06-02 18:45:003200
1042024-06-20 11:00:006700
4 rows
SELECT EXTRACT(MONTH FROM ordered_at) AS month_num, SUM(amount) AS total FROM orders_multi_year GROUP BY EXTRACT(MONTH FROM ordered_at);
LEGEND
Rows read / loaded
① Hypothetical data
data spanning yearsSuppose data for May 2024 and May 2025 are mixed together.
1 / 3
ordered_atamount
'2024-05-03'8000
'2025-05-15'12500
2 rows
LEARNING POINTS
DATE_TRUNC works at various precisions: 'year'/'quarter'/'month'/'week'/'day'/'hour'/'minute' and more are available. For weekly aggregation use DATE_TRUNC('week', ordered_at), and for yearly just change it to 'year'.
EXTRACT pulls out a number (unsuitable as a grouping key): 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.
Main TO_CHAR format codes: 'YYYY'=4-digit year, 'MM'=2-digit month, 'DD'=2-digit day, 'HH24'=hour in 24-hour notation, 'MI'=minutes, 'SS'=seconds. Combine them like TO_CHAR(ts, 'YYYY-MM-DD HH24:MI:SS').
ANTI-PATTERNS
GROUP BY with EXTRACT(MONTH FROM ...) alone: when the data spans multiple years, May 2024 and May 2025 land in the same group. For monthly aggregation, always use DATE_TRUNC('month', ...).
Storing dates as TEXT and filtering by month with LIKE: 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'.
Field note: time zones and DATE_TRUNC
In production it is important to make the time zone explicit, as in 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.
QUESTION 8
ROUND / CEIL / FLOOR — rounding numbers (compared on tax-inclusive calculation)
ROUNDCEIL/FLOORNumeric ShapingTax-Inclusive Calc
Background

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
Which to use for consumption-tax calculation: under Japanese consumption-tax law, rounding down, rounding up, and rounding half up are all legal. Rounding fractions down (FLOOR) is common in general, but choose to match your system spec. Use NUMERIC rather than FLOAT for monetary calculations to guarantee precision.
Problem

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).

Tables used
▸ products
product_idnamepricetax_rate
1Coffee4860.08
2Notebook820.10
3Pen950.10
Expected Output

Expected output (comparison of rounding methods):

product_idnamepricetax_rawtax_roundtax_ceiltax_floor
1Coffee486524.88525525524
2Notebook8290.20909190
3Pen95104.50105105104

※ Notebook (90.20): note the difference CEIL=91 vs ROUND=FLOOR=90. Even a small fraction is always rounded up by CEIL.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT product_id, name, price, tax_rate, price * (1 + tax_rate) AS tax_raw, ROUND(price * (1 + tax_rate)) AS tax_round, CEIL( price * (1 + tax_rate)) AS tax_ceil, FLOOR(price * (1 + tax_rate)) AS tax_floor FROM products ORDER BY product_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
product_idnamepricetax_rate
1Coffee4860.08
2Notebook820.1
3Pen950.1
3 rows
SELECT name, price, ROUND(price, -1) AS round_10, ROUND(price, -2) AS round_100 FROM products;
LEGEND
Rows read / loaded
① FROM
FROM productsThe original price data.
1 / 3
nameprice
Coffee486
Notebook82
Pen95
3 rows
LEARNING POINTS
ROUND can take a number of decimal places: as in 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.
CEIL is the "never lose out" rounding: when you want to round in the direction favorable to you in a pricing calculation (the direction that secures revenue), CEIL is the standard; FLOOR is the standard when favoring the user (rounding down); and ROUND is standard for neutral. If the system spec says "fraction handling: round up," use CEIL.
Monetary calculation with FLOAT causes precision errors: the problem where 0.1 + 0.2 becomes 0.30000000000000004 in FLOAT is famous. Always use the NUMERIC or DECIMAL type for monetary calculations.
ANTI-PATTERNS
Storing money in a FLOAT type: the 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).
Storing to the DB without rounding on the app side: storing amounts with fractions as-is in the DB can make totals drift during aggregation. Decide at design time whether to store "the value after per-item fraction handling" in the DB, or "store with the fraction and ROUND at display time," and standardize on one.
Field note: implementation patterns for Japanese consumption-tax calculation
In Japanese consumption tax there is no legal mandate on handling fractions below one yen, but the invoice system (qualified invoices) stipulates that fraction handling be performed once per qualified invoice. As a SQL implementation, 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.
QUESTION 9
STRING_AGG — aggregate strings into one row per group
STRING_AGGGROUP BYString AggregationOrder Details
Background

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)
It can embed an ORDER BY: specifying 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).
Problem

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.

Tables used
▸ order_items
order_idproduct_namequantity
101Coffee2
101Sandwich1
102Coffee1
102Cake1
102Tea2
103Sandwich3
Expected Output

Expected output (products aggregated per order):

order_idproductstotal_qty
101Coffee, Sandwich3
102Cake, Coffee, Tea4
103Sandwich3

※ Because ORDER BY product_name is specified, the values are sorted alphabetically.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT order_id, STRING_AGG(product_name, ', ' ORDER BY product_name) AS products, SUM(quantity) AS total_qty FROM order_items GROUP BY order_id ORDER BY order_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
order_idproduct_namequantity
101Coffee2
101Sandwich1
102Coffee1
102Cake1
102Tea2
103Sandwich3
6 rows (3 orders)
SELECT order_id, ARRAY_AGG(product_name ORDER BY product_name) AS product_array FROM order_items GROUP BY order_id;
LEGEND
Rows read / loaded
① FROM
FROM order_itemsData with multiple rows for the same order_id.
1 / 2
order_idproduct_name
101Coffee
101Sandwich
102Coffee
102Cake
102Tea
103Sandwich
6 rows
LEARNING POINTS
Omitting ORDER BY makes the order nondeterministic: STRING_AGG without ORDER BY can yield different results depending on the execution plan and the physical order of the data. This causes bugs where different orders come back between the test environment and production. Always specify ORDER BY.
ARRAY_AGG can aggregate into an array: 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.
The MySQL counterpart is GROUP_CONCAT: in MySQL use GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ', '). The syntax differs but the functionality is equivalent.
ANTI-PATTERNS
An extremely long string is generated: when a group has many rows, the STRING_AGG result can reach several MB. Watch the DB's maximum string length and network bandwidth, and cap it as needed with something like LEFT(STRING_AGG(...), 500).
Behavior when NULLs are present: STRING_AGG skips NULL values (NULLs are excluded from the aggregation). If you want to include a NULL as '(none)', use COALESCE(product_name, '(none)').
Field note: building nested API-response data in SQL
A REST API response like "return an order list with a nested product list" is often assembled on the app side by issuing N+1 queries, but with STRING_AGG or ARRAY_AGG + JSON functions (json_agg, etc.) it can be done in a single query. Combined with a PostgreSQL pattern such as json_agg(row_to_json(items)), you can even generate a complex nested structure with SQL alone.
QUESTION 10
REPLACE / REGEXP_REPLACE — normalize phone numbers with pattern replacement
REPLACEREGEXP_REPLACERegular ExpressionsData Normalization
Background

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 difference between REPLACE and REGEXP_REPLACE: REPLACE only replaces a fixed string. REGEXP_REPLACE can use regular-expression patterns, enabling flexible replacement such as "all non-digit characters," "two or more whitespace characters," or "any given format."
Problem

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.

Tables used
▸ contacts
contact_idphone
103-1234-5678
2(090) 9876-5432
30120.000.111
Expected Output

Expected output (note that rows 2 and 3 differ):

contact_idoriginalreplace_hyphenregexp_digits_only
103-1234-567803123456780312345678
2(090) 9876-5432(090) 9876543209098765432
30120.000.1110120.000.1110120000111

※ 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.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT contact_id, phone AS original, REPLACE(phone, '-', '') AS replace_hyphen, REGEXP_REPLACE(phone, '[^0-9]', '', 'g') AS regexp_digits_only FROM contacts ORDER BY contact_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
contact_idphone (original)
103-1234-5678
2(090) 9876-5432
30120.000.111
3 rows (mixed formats)
SELECT '0312345678' AS phone_raw, REGEXP_REPLACE( '0312345678', '^(\d{2})(\d{4})(\d{4})$', '\1-\2-\3' ) AS formatted_phone;
LEGEND
Rows read / loaded
① FROM
digits-only phone numberSuppose there is a 10-digit phone number stored without hyphens.
1 / 2
phone_raw
'0312345678'
1 row
LEARNING POINTS
Basic regex patterns: [^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.
The 'g' flag for replacing all: without the 'g' (global) flag, only the first match is replaced. When multiple replacements are needed, as in removing hyphens from a phone number, always add 'g'.
REGEXP_REPLACE can also reformat: as in REGEXP_REPLACE('0312345678', '^(\d{2})(\d{4})(\d{4})$', '\1-\2-\3') → '03-1234-5678', you can reformat using grouping (parentheses).
ANTI-PATTERNS
Chaining multiple REPLACEs: nesting REPLACE many times as in REPLACE(REPLACE(REPLACE(phone,'-',''),'(',''),')','') ... makes the code cumbersome. Handle it in one shot with REGEXP_REPLACE.
Overly complex regular expressions: a full validation regex for an email address becomes dozens of characters and is hard to maintain. Keep patterns simple in SQL and do complex validation in the application layer for better maintainability.
Field note: it is better to normalize data at INSERT time
Running REGEXP_REPLACE on the phone-number format every time at SELECT costs CPU. Ideally the design is to "store the normalized value at INSERT or UPDATE time and retrieve it as-is at SELECT." Practical approaches include enforcing the format at the DB level with a CHECK constraint 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.