Practice5problems on this pattern, easy to hardStart practising →
Pattern 13SQL course / Working with Real-World Data

SQL data types and CAST: converting values safely

A price can look like a number while SQL treats it as text. That changes how it compares, sorts and contributes to a calculation.

16 min read13 sections8 predictions5 practice problems

In this lesson, you will convert text into numbers, dates and flags, choose suitable types for calculations, and identify values that need attention before they reach a report.

The examples run in DuckDB. PostgreSQL differences appear alongside the relevant examples, with other database differences collected in §12.

The sample data

We continue with the shop examples from the previous lessons. Its five customers are unchanged, but a partner marketplace has supplied eight new orders. The shop has loaded every column in marketplace_orders as text.

customers5 rows
customer_idcustomer_namecitycountryreferred_by_customer_id
1AshaBengaluruIndiaNULL
2BenLondonUK1
3CarlaDubaiUAE1
4DevBengaluruIndia2
5EshaSingaporeSingaporeNULL
marketplace_orders8 rows
order_refcustomer_refproductquantityunit_pricediscountordered_atgift_wrap
M-5011Wireless Mouse27500.102026-06-01 10:15:00no
M-5022Mechanical Keyboard13200NULL2026-06-02 09:40:00yes
M-5033Webcam11200''2026-06-02 21:05:00no
M-5044Office Chair185000.052026-06-03 14:30:00no
M-50502USB-C Cable3900.202026-06-04 08:00:00Y
M-5065Desk Lamp11,800NULL04/06/2026 18:20yes
M-507guestMonitor Stand136000.102026-06-05 12:00:001
M-5081Desk Lamp21.800NULL2026-06-06 16:45:00no

A few details about the source matter:

  • customer_ref contains the shop’s numeric customer ID. Some sellers add a leading zero, so 02 still means Ben, customer 2. The value guest means there is no customer ID.
  • Prices are in rupees. Both desk lamps cost 1,800 each. One seller writes 1,800; the German seller writes 1.800, using a full stop as the thousands separator.
  • Discounts are fractions: 0.10 means 10%. M-503 has an empty string, while some other orders have NULL.
  • M-506 comes from the India storefront. Its date, 04/06/2026, means 4 June 2026.
  • Gift-wrapping choices appear as yes, no, Y and 1.
01

Values and their types

Every SQL value has a data type. The type determines which operations are available and how SQL interprets the value.

In DuckDB, typeof() shows the type of an expression:

Query
SELECT
typeof(8500) AS whole_number,
typeof('8500') AS quoted,
typeof(85.50) AS with_decimals,
typeof(DATE '2026-06-01') AS a_date,
typeof(true) AS a_flag;
Result1 row
whole_numberquotedwith_decimalsa_datea_flag
INTEGERVARCHARDECIMAL(4,2)DATEBOOLEAN

Here, 8500 is a number and '8500' is text containing four digits. They may look identical in a result table, but their types allow different operations. As we saw in P12, || joins text, while + adds numbers:

Query
SELECT
8500 + 1 AS added,
'8500' || '1' AS joined;
Result1 row
addedjoined
850185001

The main type families are:

FamilyDuckDB typesWhat they representCommon uses
Whole numbersINTEGER, BIGINT, HUGEINTWhole numbers within a fixed rangeQuantities, counts, numeric IDs
DecimalsDECIMAL(p, s), also called NUMERICNumbers with a specified number of total and fractional digitsMoney, discount rates
Floating pointDOUBLE, FLOATNumbers stored using a binary floating-point representationMeasurements, scores
TextVARCHAR, also called TEXTSequences of charactersNames, codes, imported values
Dates and timesDATE, TIMESTAMP, TIMESTAMPTZ, INTERVALCalendar dates, dates with times, instants, and intervalsOrder times, deadlines, durations
True or falseBOOLEANtrue or false, with NULL available for missing informationFlags and choices
Note
PostgreSQL note
Use pg_typeof() instead of DuckDB’s typeof(). PostgreSQL also treats a quoted literal differently from an already typed text column when deciding whether to convert it; we will return to that distinction in §10.
02

Orders above a price

The merchandising team wants all marketplace orders with a unit price above 1,000 rupees. The analyst knows that unit_price is text, so they put the comparison value in quotes too.

Pause and predictPredict before you continue

Which orders does this query return?

The query
SELECT order_ref, unit_price
FROM marketplace_orders
WHERE unit_price > '1000'
ORDER BY order_ref;

Sorting the same column follows the same rule:

Query
SELECT order_ref, unit_price
FROM marketplace_orders
ORDER BY unit_price;
Result8 rows
order_refunit_price
M-5061,800
M-5081.800
M-5031200
M-5023200
M-5073600
M-501750
M-5048500
M-50590
Tip
Text comparisons follow text ordering. Convert amounts to suitable numeric values before comparing or sorting them by size.
03

Converting with CAST

CAST(value AS type) converts a value to another data type.

DuckDB and PostgreSQL also support the shorter form, value::type. We use CAST throughout this lesson because its syntax is widely supported. Let’s convert three prices:

Query
SELECT
order_ref,
unit_price,
CAST(unit_price AS INTEGER) AS price
FROM marketplace_orders
WHERE order_ref IN ('M-501', 'M-502', 'M-505')
ORDER BY order_ref;
Result3 rows
order_refunit_priceprice
M-501750750
M-50232003200
M-5059090

The values look unchanged, but price is now an INTEGER. SQL can compare, sort and calculate with it as a whole number. This conversion also accepts surrounding spaces, leading zeros and a plus sign:

Query
SELECT
CAST(' 750 ' AS INTEGER) AS spaces,
CAST('0750' AS INTEGER) AS leading_zero,
CAST('+750' AS INTEGER) AS plus_sign;
Result1 row
spacesleading_zeroplus_sign
750750750
Pause and predictPredict before you continue

The analyst now adds the conversion to the price filter. Which orders does this query return?

The query
SELECT order_ref, unit_price
FROM marketplace_orders
WHERE CAST(unit_price AS INTEGER) > 1000
ORDER BY order_ref;
Tip
CAST converts a value or raises an error.
04

Text that holds numbers

People format numbers with thousands separators, currency symbols and different decimal marks. Before removing that formatting, we need to know what it means. For M-506, the comma in 1,800 separates thousands. The analyst removes commas with replace() and then converts the result.

Pause and predictPredict before you continue

What does price show for M-508?

The query
SELECT
order_ref,
unit_price,
CAST(replace(unit_price, ',', '') AS INTEGER) AS price
FROM marketplace_orders
WHERE order_ref IN ('M-506', 'M-508')
ORDER BY order_ref;
Note
PostgreSQL note
PostgreSQL rejects text such as '1.800' or '3.5' when casting directly to INTEGER. It does not round these strings as DuckDB does. Converting an already numeric value is a separate case.

A better approach starts with an explicit rule for acceptable input. Suppose we accept either:

  • Digits with no separators, such as 1800.
  • Groups of three digits separated by commas, such as 1,800.

We can check that rule with a regular expression before converting:

Query
SELECT
order_ref,
unit_price,
CASE
WHEN regexp_matches(
unit_price,
'^[0-9]+$|^[0-9]{1,3}(,[0-9]{3})+$'
)
THEN CAST(replace(unit_price, ',', '') AS INTEGER)
END AS price
FROM marketplace_orders
ORDER BY order_ref;
Result8 rows
order_refunit_priceprice
M-501750750
M-50232003200
M-50312001200
M-50485008500
M-5059090
M-5061,8001800
M-50736003600
M-5081.800NULL

We can now review the value instead of silently assigning the wrong price. Once the seller’s formatting convention is known, we can apply a separate rule to that seller’s data. This pattern checks the format, not the numeric range. A very long string of digits could still exceed the maximum INTEGER value.

Note
PostgreSQL note
DuckDB’s regexp_matches() returns a Boolean. PostgreSQL’s function with that name returns matching text instead. For this condition, you can use PostgreSQL’s ~ operator, or regexp_like() in PostgreSQL 15 and later.

Some values containing digits should remain text. For example, postal codes, phone numbers and account codes may depend on their leading zeros:

Query
SELECT
CAST('018956' AS INTEGER) AS as_number,
CAST(CAST('018956' AS INTEGER) AS VARCHAR) AS back_to_text;
Result1 row
as_numberback_to_text
1895618956

The Singapore postal code 018956 loses its leading zero. Converting the number back to text cannot recover information that was discarded.

05

Values that can’t be converted

DuckDB provides TRY_CAST for conversions that should allow processing to continue when a value cannot be converted.

Finance wants the total order value: quantity multiplied by unit price, added across all eight orders, before discounts. The analyst uses TRY_CAST on both columns.

Pause and predictPredict before you continue

What does revenue show?

The query
SELECT
SUM(
TRY_CAST(quantity AS INTEGER)
* TRY_CAST(unit_price AS INTEGER)
) AS revenue
FROM marketplace_orders;

A report should show how much data contributed to its total. We can use the format rule from §04 and count the prices that remain unresolved:

Query
WITH priced AS (
SELECT
order_ref,
CAST(quantity AS INTEGER) AS quantity,
CASE
WHEN regexp_matches(
unit_price,
'^[0-9]+$|^[0-9]{1,3}(,[0-9]{3})+$'
)
THEN CAST(replace(unit_price, ',', '') AS INTEGER)
END AS price
FROM marketplace_orders
)
SELECT
COUNT(*) AS orders,
COUNT(price) AS priced,
SUM(quantity * price) AS revenue,
COUNT(*) - COUNT(price) AS to_review
FROM priced;
Result1 row
orderspricedrevenueto_review
87200701

The report now makes its limitation visible: seven of the eight orders contribute to the total, and one needs review. All quantities in this sample are valid. If quantities could also be missing or invalid, the report would need to check both fields before counting an order as usable.

Empty strings need a deliberate rule too. This conversion fails on M-503:

Query
SELECT
order_ref,
CAST(discount AS DECIMAL(4,2)) AS discount_rate
FROM marketplace_orders
ORDER BY order_ref;

Result: A conversion error because '' cannot be converted to DECIMAL(4,2).

An empty string is text with no characters. It is not automatically a missing numeric value. If the shop’s rule is that a blank discount means “not supplied,” we can express that directly:

Query
SELECT
order_ref,
discount,
CAST(
NULLIF(trim(discount), '')
AS DECIMAL(4,2)
) AS discount_rate
FROM marketplace_orders
ORDER BY order_ref;
Result8 rows
order_refdiscountdiscount_rate
M-5010.100.10
M-502NULLNULL
M-503''NULL
M-5040.050.05
M-5050.200.20
M-506NULLNULL
M-5070.100.10
M-508NULLNULL

The expression works in three steps:

  1. trim() removes surrounding spaces.
  2. NULLIF(..., '') turns an empty result into NULL.
  3. CAST converts the remaining text to a decimal.

This treats blanks as missing while allowing unexpected text, such as 'abc', to raise an error. Replacing the whole expression with TRY_CAST would turn both cases into NULL, hiding that distinction unless we track it separately.

Note
PostgreSQL note
PostgreSQL has no built-in TRY_CAST. Section 12 shows an input-validation approach available from PostgreSQL 16.
06

Arithmetic on whole numbers

The shop awards one loyalty point for every complete 100 rupees spent. An amount of 250 rupees should earn two points. The analyst divides the order amount by 100 and converts the result to INTEGER.

Pause and predictPredict before you continue

What does points show for M-505?

The query
SELECT
order_ref,
CAST(quantity AS INTEGER)
* CAST(unit_price AS INTEGER) AS amount,
CAST(
CAST(quantity AS INTEGER)
* CAST(unit_price AS INTEGER)
/ 100
AS INTEGER
) AS points
FROM marketplace_orders
WHERE order_ref = 'M-505';

Compare the alternatives:

Query
SELECT
270 / 100 AS divided,
CAST(270 / 100 AS INTEGER) AS cast_to_integer,
270 // 100 AS integer_division,
floor(270 / 100) AS floored;
Result1 row
dividedcast_to_integerinteger_divisionfloored
2.7322.0

floor() rounds down to the nearest whole number. It gives the intended two points.

DuckDB’s // performs integer division for integer operands, discarding the fractional part. For these positive amounts, it produces the same whole-number result.

However, discarding a fraction and rounding down differ for negative values:

  • -7 // 2 gives -3, moving towards zero.
  • floor(-7 / 2) gives -4, moving downward.
Database note

In PostgreSQL, SQL Server and SQLite, / between integer operands already discards the fractional part. For example, 7 / 2 gives 3. Convert an operand to a suitable decimal or floating-point type before division when you need a fractional result; in SQLite, use REAL. PostgreSQL has no // operator.

Whole numbers also have limits. DuckDB’s INTEGER can hold values up to 2,147,483,647. Suppose a bulk order contains 3,000 items, each priced at 850,000 paise:

Query
SELECT
CAST('3000' AS INTEGER)
* CAST('850000' AS INTEGER) AS amount_in_paise;

Result: An overflow error. The product is 2,550,000,000, which is larger than an INTEGER can hold.

Convert an operand to BIGINT before multiplying. Casting the product afterwards is too late: the multiplication has already overflowed. BIGINT holds signed values up to roughly 9.2 × 10¹⁸.

07

Numbers with decimals

The shop plans a promotion that adds 20 percentage points to each order’s existing discount rate. A discount of 0.10 would therefore become 0.30. Any total rate above 0.30 needs approval. The analyst converts the stored rates to DOUBLE.

Pause and predictPredict before you continue

Which orders does this query flag for approval?

The query
SELECT
order_ref,
discount,
CAST(NULLIF(trim(discount), '') AS DOUBLE)
+ 0.2 AS total_discount
FROM marketplace_orders
WHERE CAST(NULLIF(trim(discount), '') AS DOUBLE)
+ 0.2 > 0.3
ORDER BY order_ref;

For rates that require exact decimal comparisons, use DECIMAL:

Query
SELECT
order_ref,
discount,
CAST(NULLIF(trim(discount), '') AS DECIMAL(4,2))
+ 0.2 AS total_discount
FROM marketplace_orders
WHERE CAST(NULLIF(trim(discount), '') AS DECIMAL(4,2))
+ 0.2 > 0.3
ORDER BY order_ref;
Result1 row
order_refdiscounttotal_discount
M-5050.200.40

Only M-505 exceeds the threshold.

Missing discounts remain NULL in both queries, so those orders do not pass the filter. Treating a missing discount as zero would require a separate business rule.

In DECIMAL(p, s):

  • Precision, p, is the total number of digits.
  • Scale, s, is the number of digits after the decimal point.

For example, DECIMAL(4,2) holds values from -99.99 to 99.99, in steps of 0.01.

The literals in the query matter too:

Query
SELECT
typeof(0.2) AS literal_type,
0.1 + 0.2 = 0.3 AS decimals_equal,
CAST('0.1' AS DOUBLE)
+ CAST('0.2' AS DOUBLE) = 0.3 AS doubles_equal;
Result1 row
literal_typedecimals_equaldoubles_equal
DECIMAL(2,1)truefalse

In DuckDB, a plain literal such as 0.2 is a decimal. PostgreSQL similarly treats ordinary decimal-point literals as NUMERIC.

08

Text that holds dates

The marketplace’s timestamps also arrived as text. Most use a year-first format, so the analyst tries converting the whole column:

Query
SELECT
order_ref,
CAST(ordered_at AS TIMESTAMP) AS ordered_ts
FROM marketplace_orders
ORDER BY order_ref;

Result: A conversion error for M-506’s value, '04/06/2026 18:20'.

DuckDB can cast supported standard timestamp forms, but it does not accept this day-first format through this cast.

For a known text format, use strptime(text, format). Its format string tells DuckDB which parts represent the year, month, day and time.

PatternMeaning
%YFour-digit year
%mMonth number
%dDay of the month
%HHour on a 24-hour clock
%MMinute
%SSecond

The same text can describe two different timestamps:

Query
SELECT
strptime(
'04/06/2026 18:20',
'%d/%m/%Y %H:%M'
) AS day_first,
strptime(
'04/06/2026 18:20',
'%m/%d/%Y %H:%M'
) AS month_first;
Result1 row
day_firstmonth_first
2026-06-04 18:20:002026-04-06 18:20:00

Both conversions succeed. Only the first matches the source’s intended date. DuckDB’s try_strptime() returns NULL when parsing fails.

Pause and predictPredict before you continue

What does ordered_ts show for M-506?

The query
SELECT
order_ref,
ordered_at,
try_strptime(
ordered_at,
[
'%Y-%m-%d %H:%M:%S',
'%m/%d/%Y %H:%M',
'%d/%m/%Y %H:%M'
]
) AS ordered_ts
FROM marketplace_orders
WHERE order_ref IN ('M-505', 'M-506')
ORDER BY order_ref;

Moving the day-first format earlier would fix this order, but could misread an order from a month-first source. Format order cannot resolve information that is missing from the text.

We can identify values for which both interpretations succeed but disagree:

Query
SELECT
order_ref,
ordered_at,
try_strptime(
ordered_at,
'%m/%d/%Y %H:%M'
) AS month_first,
try_strptime(
ordered_at,
'%d/%m/%Y %H:%M'
) AS day_first
FROM marketplace_orders
WHERE try_strptime(ordered_at, '%m/%d/%Y %H:%M')
<> try_strptime(ordered_at, '%d/%m/%Y %H:%M');
Result1 row
order_refordered_atmonth_firstday_first
M-50604/06/2026 18:202026-04-06 18:20:002026-06-04 18:20:00

This finds ambiguous values with two different valid results. It does not find every invalid date: a failed parse produces NULL, and a comparison involving NULL does not pass this filter. Check failed parses separately.

A value such as 13/06/2026 fits only the day-first interpretation because 13 cannot be a month. A value such as 04/04/2026 produces the same date either way.

For ambiguous values, use information from the source: the storefront, file specification or seller’s documented format.

A date stored numerically as 20260604 needs its own rule. Convert it to text and parse it with %Y%m%d; casting the integer directly to DATE does not explain how its digits should be interpreted.

Note
PostgreSQL note
PostgreSQL uses to_date(text, 'DD/MM/YYYY') for a stated date format, and to_timestamp(text, 'DD/MM/YYYY HH24:MI') for a date and time. Its format patterns differ from DuckDB’s. Also, PostgreSQL’s two-argument to_timestamp() returns TIMESTAMPTZ; when no zone is supplied, the session time zone matters. PostgreSQL formatting functions
09

Yes, no and other flags

The gift_wrap column represents a yes-or-no choice, but the marketplace uses several spellings.

DuckDB accepts all the spellings in this sample:

Query
SELECT
order_ref,
gift_wrap,
CAST(gift_wrap AS BOOLEAN) AS gift
FROM marketplace_orders
ORDER BY order_ref;
Result8 rows
order_refgift_wrapgift
M-501nofalse
M-502yestrue
M-503nofalse
M-504nofalse
M-505Ytrue
M-506yestrue
M-5071true
M-508nofalse

DuckDB accepts true and false, t and f, yes and no, y and n, and 1 and 0, without regard to letter case.

These rules are database-specific. For example, PostgreSQL also accepts on, off and surrounding whitespace. DuckDB does not accept those additional forms in these text-to-Boolean casts. PostgreSQL Boolean input rules

We can count gift orders and calculate their share:

Query
SELECT
COUNT(*) FILTER (
WHERE CAST(gift_wrap AS BOOLEAN)
) AS gift_orders,
AVG(
CAST(CAST(gift_wrap AS BOOLEAN) AS INTEGER)
) AS gift_share
FROM marketplace_orders;
Result1 row
gift_ordersgift_share
40.5

The filtered count includes rows where the flag is true. For the average, converting the Boolean to INTEGER turns true into 1 and false into 0. Four ones and four zeros have an average of 0.5, or 50%. The extra conversion is necessary:

Query
SELECT AVG(CAST(gift_wrap AS BOOLEAN)) AS gift_share
FROM marketplace_orders;

Result: An error because AVG does not accept BOOLEAN in DuckDB.

DuckDB does allow SUM of Boolean values, but PostgreSQL accepts neither SUM(BOOLEAN) nor AVG(BOOLEAN). Converting the flag to an integer makes the calculation explicit in both databases.

If some flags were NULL, AVG would exclude them. Its result would then be the share among orders with known flags. Report the number of unknown flags if they could affect the interpretation.

For values typed by people, an explicit mapping gives you control over the accepted vocabulary:

Query
SELECT
order_ref,
CASE
WHEN lower(trim(gift_wrap))
IN ('yes', 'y', 'true', '1') THEN true
WHEN lower(trim(gift_wrap))
IN ('no', 'n', 'false', '0') THEN false
END AS gift
FROM marketplace_orders
ORDER BY order_ref;
Result8 rows
order_refgift
M-501false
M-502true
M-503false
M-504false
M-505true
M-506true
M-507true
M-508false

This version removes surrounding spaces, ignores case and accepts only the listed values. Anything else becomes NULL for review.

10

Joining marketplace orders to customers

The shop’s customers.customer_id is an integer. The marketplace’s customer_ref is text. Before joining them, consider a simpler comparison:

Query
SELECT customer_id, customer_name
FROM customers
WHERE customer_id = '2';
Result1 row
customer_idcustomer_name
2Ben

The comparison works even though 2 is quoted. The database can interpret this literal as an integer because of the surrounding expression. This is an implicit conversion: a conversion the database performs without an explicit CAST.

DuckDB and PostgreSQL both accept this query. For an integer ID, writing customer_id = 2 still makes the intended type clearer.

Pause and predictPredict before you continue

Now the analyst joins the tables directly. How many orders does this query return?

The query
SELECT m.order_ref, c.customer_name
FROM marketplace_orders AS m
JOIN customers AS c
ON c.customer_id = m.customer_ref
ORDER BY m.order_ref;
Note
PostgreSQL note
PostgreSQL rejects the comparison between an already typed text column and an integer column. A quoted literal such as '2' can take its type from context; a text column already has a type. These are different situations.

In DuckDB, we can make the intended conversion explicit and allow references without a numeric value to become NULL:

Query
SELECT
m.order_ref,
m.customer_ref,
c.customer_name
FROM marketplace_orders AS m
JOIN customers AS c
ON c.customer_id = TRY_CAST(m.customer_ref AS INTEGER)
ORDER BY m.order_ref;
Result7 rows
order_refcustomer_refcustomer_name
M-5011Asha
M-5022Ben
M-5033Carla
M-5044Dev
M-50502Ben
M-5065Esha
M-5081Asha

M-505’s '02' becomes 2 and matches Ben.

M-507’s 'guest' becomes NULL. It cannot satisfy the equality condition, so the inner join excludes it. A left join would retain the order with a missing customer name. Here, guest is an expected marker. Unexpected invalid references should also be counted and reviewed, rather than quietly disappearing from an inner join.

We could instead convert the integer IDs to text:

Query
SELECT
m.order_ref,
m.customer_ref,
c.customer_name
FROM marketplace_orders AS m
JOIN customers AS c
ON CAST(c.customer_id AS VARCHAR) = m.customer_ref
ORDER BY m.order_ref;
Result6 rows
order_refcustomer_refcustomer_name
M-5011Asha
M-5022Ben
M-5033Carla
M-5044Dev
M-5065Esha
M-5081Asha

This query runs, but M-505 no longer matches. The text '02' is different from '2'. Automatic conversion also depends on the operator. DuckDB accepts the mixed-type equality comparison above, but rejects this range comparison:

Query
SELECT order_ref, unit_price
FROM marketplace_orders
WHERE unit_price > 1000;

Result: An error because the comparison mixes VARCHAR and an integer literal without an explicit cast.

Expressions that combine values also need a common result type:

Query
SELECT 1 AS value
UNION ALL
SELECT 'two';
Result2 rows
value
1
two

DuckDB chooses text for the result column, so the first row contains the text representation of 1. PostgreSQL instead tries to interpret the untyped literal 'two' as an integer and raises an error. PostgreSQL type resolution

COALESCE and CASE also need compatible result types. For example, in DuckDB:

Query
COALESCE(CAST(NULLIF(trim(discount), '') AS DECIMAL(4,2)), 'none')

tries to convert 'none' to a decimal when that fallback is needed, causing an error. If the result is a display label, convert the numeric value to text before combining it with a text label.

11

How to spot type problems

Some type problems stop a query immediately. Others produce plausible results with the wrong order, amount, date or match.

Use these clues to decide what to check:

What you noticeWhat to checkSection
Prices are sorted as 1,800, 1200, 750, 90Is the column text rather than numeric?§02
A price filter keeps small amounts and drops larger onesAre both sides being compared as text?§02
A previously working query now fails during conversionHas a new format, blank or unexpected value arrived?§03, §05
A converted price is much too small or too largeDoes the source use a different decimal mark or thousands separator?§04
Codes lose their leading zerosWere identifiers converted to numbers?§04
A total is too low, but the query succeedsDid TRY_CAST produce NULL values that SUM ignored?§05
Points or shares are rounded unexpectedly, or become zeroDid integer division or a cast discard or round the fractional part?§06
Multiplication raises an overflow errorCan the intermediate product fit in its numeric type?§06
A value near a threshold falls on the wrong sideIs floating-point approximation affecting the comparison?§07
A valid-looking amount will not fit in a decimalDoes the precision allow enough digits before the decimal point?§07
Dates appear in the wrong month without an errorWere day and month interpreted in the wrong order?§08
A flag converts in one database but fails in anotherWhich text spellings does each database accept?§09
A join fails or misses expected matchesAre the keys different types, and is the conversion direction appropriate?§10

Before writing the query, answer four questions:

  1. What type does the value have now? Inspect the type rather than inferring it from the displayed value.
  2. What does the value represent? Choose a type for its meaning: an amount, identifier, flag, calendar date or timestamp.
  3. Which source formats are allowed? Establish the rules for separators, decimal marks, date order and flag spellings.
  4. What should happen when conversion fails? Decide whether to stop, record a missing result for review, or apply a documented correction.
12

Data types across engines

The principles in this lesson apply across databases, but conversion behaviour is not identical. Before moving a query, check the available types, accepted input formats, rounding rules and treatment of failed conversions.

For PostgreSQL, the main differences are:

TaskPostgreSQL behaviour
Inspect an expression’s typeUse pg_typeof().
Convert without stopping on invalid inputThere is no built-in TRY_CAST. PostgreSQL 16 and later provide input-validation functions.
Divide integers270 / 100 gives 2. There is no DuckDB-style //.
Convert fractional text to an integerText such as '3.5' and '1.800' raises an error.
Compare a text column with an integer columnAn explicit conversion is required. An untyped quoted literal is a different case.
Parse datesA plain cast can depend on DateStyle; use to_date() or to_timestamp() with the intended format.
Work with Boolean flagsAdditional spellings include on and off. Convert to a number before using SUM or AVG.
Combine 1 and 'two' with UNIONPostgreSQL tries to convert 'two' to an integer and fails.

For the integer conversion in this lesson, PostgreSQL 16 and later can use:

Query
CASE WHEN pg_input_is_valid(unit_price, 'integer') THEN CAST(unit_price AS INTEGER) END

This checks whether the text is valid input for the integer type, including whether it fits its range. Check support before applying the same approach to other types. A regular expression alone checks the text’s shape, not whether the value fits. PostgreSQL input-validation functions

Other databases have their own important differences:

DatabaseMain differences to check
MySQLSome numeric conversions in SELECT use leading digits or produce zero with a warning. / can produce a fractional result; DIV performs integer division. Use DECIMAL for exact decimal values and STR_TO_DATE() for a specified date format. BOOLEAN is an alias for TINYINT(1). See the type-conversion reference.
SQL ServerTRY_CAST and TRY_CONVERT return NULL for many conversion failures, although prohibited conversions still raise errors. Integer division discards the fraction, and converting a numeric value such as 2.7 to INT gives 2. Flags commonly use BIT. CONVERT supports date styles, including 103 for day-first dates and 101 for month-first dates. See TRY_CAST and CAST and CONVERT.
OracleNUMBER(p, s) provides decimal storage. 7 / 2 gives 3.5, while conversion to an integer type rounds 2.7 to 3. Supported conversions can use DEFAULT NULL ON CONVERSION ERROR, and VALIDATE_CONVERSION can check input. Use TO_DATE() with a format for text dates. SQL Boolean columns are available from Oracle Database 23ai. See the CAST reference.
SQLiteOrdinary tables use flexible type affinity; STRICT tables add stronger checks. Casting text to an integer can keep its leading digits or return zero, so both '1,800' and '1.800' become 1. Integer division discards the fraction, and SQLite has no built-in exact decimal storage type. See SQLite expressions and casts.
Snowflake and BigQueryUse Snowflake’s TRY_CAST or BigQuery’s SAFE_CAST for supported conversions that should return NULL on conversion failure.

Conversion direction matters in these databases too. MySQL can compare text and numbers numerically; SQL Server and Oracle generally convert the text to the numeric type. SQLite can instead compare text when the column has text affinity, repeating the problem from §02.

Key idea

When moving a query, check failed conversions, integer division, rounding, date parsing and which operand changes type.

13

Summary

A value’s type determines how SQL compares, sorts and calculates with it. Check the actual types, choose types that match the data’s meaning, and use explicit conversions where needed. Keep codes as text when their characters matter, and use suitable decimal types for amounts and rates.

Conversion also needs a rule for the source format. A successful cast can misread a price or date, while a failed TRY_CAST can quietly remove a value from a calculation. Validate formats, count unresolved values, and define rounding and missing-value rules before relying on a report. When importing files, establish these decisions once so later queries use consistent data.

Next, P14 covers JSON and semi-structured data: working with fields, nested objects and arrays, and converting their values into useful SQL types.

Go deeper

Ask Colearn about this pattern

Not sure why a numeric filter returns odd rows, or when an implicit conversion will bite you? Ask about the concept, or paste a simplified version of your query.

Instant answers, grounded in the same verified material the diagnostic grades against.

3 of 3 free questions left
Why does comparing a text column with '1000' return unexpected rows?

Because SQL is comparing text rather than numeric amounts.

Under DuckDB’s default text ordering, the first different character decides the result. For example, '90' is greater than '1000' because 9 comes after 1, even though 90 is numerically smaller than 1,000.

Check the source’s number format, then convert the values to a suitable numeric type before comparing them. Formatted values such as '1,800' need handling before a direct integer conversion.

See §02.

Why does casting an empty-string discount fail, and how do I turn it into NULL first?

An empty string, '', contains no numeric value, so it cannot be converted to a decimal.

If a blank discount means “not supplied,” remove surrounding spaces and turn the empty result into NULL before casting:

Query
SELECT
order_ref,
CAST(
NULLIF(trim(discount), '')
AS DECIMAL(4,2)
) AS discount_rate
FROM marketplace_orders;

trim() removes surrounding spaces, NULLIF(..., '') replaces an empty string with NULL, and CAST converts the remaining values.

This works in DuckDB and PostgreSQL. Unexpected text such as 'abc' still raises an error, so it remains distinguishable from an intentionally missing discount.

See §05.

Should I compare an integer column with a quoted number or an unquoted one?

Use an unquoted number when comparing an integer column:

Query
WHERE customer_id = 2

DuckDB and PostgreSQL also accept customer_id = '2' because they can interpret that quoted literal as an integer. However, the unquoted form makes the intended type clear and avoids relying on that conversion.

A quoted literal is different from a text column. PostgreSQL can interpret '2' using the surrounding expression, but it rejects a direct comparison between an integer column and a text column.

See §10.

Is it safe to join an integer key to a text key?

It is safe when you establish what the keys mean, validate the text values, and choose the conversion explicitly.

In this lesson, '02' represents numeric customer ID 2. In DuckDB, this join converts the marketplace references to integers:

Query
SELECT
m.order_ref,
c.customer_name
FROM marketplace_orders AS m
JOIN customers AS c
ON c.customer_id = TRY_CAST(m.customer_ref AS INTEGER);

'02' matches customer 2. 'guest' becomes NULL and is excluded by the inner join. A left join would retain that order with a missing customer name.

Review unexpected invalid references rather than allowing them to disappear unnoticed. Also, if '02' and '2' are genuinely different codes, keep them as text.

PostgreSQL requires an explicit conversion between these column types and has no built-in TRY_CAST.

See §10.

What’s the difference between CAST and TRY_CAST?

In DuckDB, both attempt to convert a value to another type. Their behaviour differs when conversion fails:

Result4 rows
ExpressionResult
CAST('750' AS INTEGER)750
TRY_CAST('750' AS INTEGER)750
CAST('abc' AS INTEGER)An error
TRY_CAST('abc' AS INTEGER)NULL

Use CAST when invalid input should stop the query. Use TRY_CAST when processing should continue and unresolved values will be counted or reviewed.

Remember that SUM and AVG ignore NULL inputs. A query using TRY_CAST can therefore succeed while leaving values out of its calculation. It also cannot detect a conversion that succeeds with the wrong meaning, such as DuckDB converting '1.800' to 2.

See §03 and §05.

When should I use DECIMAL instead of DOUBLE?

Use DECIMAL when values need exact decimal storage and comparisons, such as prices and discount rates.

DOUBLE uses binary floating point, which cannot represent every decimal fraction exactly. In the lesson, adding floating-point values for 0.1 and 0.2 produces 0.30000000000000004, causing an unexpected result when checking whether the total exceeds 0.3.

With DECIMAL(p, s), p is the total number of digits and s is the number after the decimal point. Choose enough room for the values and their calculations.

DOUBLE is useful for measurements and calculations where small representation differences are acceptable. Also check the operation’s result type: in DuckDB, division involving decimals produces a floating-point result.

See §07.

Why does dividing two whole numbers give different answers in different databases?

Databases apply different rules to division between integer operands.

Result2 rows
DatabaseResult of 7 / 2
DuckDB3.5
PostgreSQL, SQL Server and SQLite3

DuckDB’s / preserves the fractional result. The other three perform integer division and discard the fractional part.

For this example, using a decimal-point literal produces a fractional result in all four databases:

Query
SELECT 7.0 / 2 AS result;

The result is 3.5. PostgreSQL displays it as 3.5000000000000000 and SQL Server as 3.500000, but the value is the same. When converting an existing integer value in SQLite, cast it to REAL; casting to DECIMAL does not reliably change integer division.

If you need only complete units, choose the appropriate operation deliberately. DuckDB provides // for integer division, while floor() rounds down. Casting a fractional result to INTEGER in DuckDB can round it instead.

See §06 and §12.

How do I read dates written as 04/06/2026?

First establish which format the source uses:

Day first: 4 June 2026.

Month first: 6 April 2026.

Both are valid dates, so SQL cannot determine the intended meaning from this text alone.

For a day-first source, DuckDB can parse the value explicitly:

Query
SELECT strptime(
'04/06/2026',
'%d/%m/%Y'
) AS ordered_at;

This returns 2026-06-04 00:00:00.

In PostgreSQL, use:

Query
SELECT to_date(
'04/06/2026',
'DD/MM/YYYY'
) AS order_date;

This returns 2026-06-04.

For month-first input, use %m/%d/%Y in DuckDB or MM/DD/YYYY in PostgreSQL. Avoid relying on a list of possible formats for ambiguous dates: the first successful format may produce the wrong date.

See §08.

Don’t paste credentials, personal data, or confidential production records.

Practise this pattern

Now write it yourselfAll 5 Data Types & CAST problems →

Graded problems on this pattern, easy to hard. They run in your browser against real tables – no sign-up needed.

Related patterns

Up next · Pattern 14Semi-structured data: JSON objects, nested fields, and arrays

Next, P14 covers JSON and semi-structured data: working with fields, nested objects and arrays, and converting their values into useful SQL types.