Type Casting: Advanced
What you will be able to do
What a value costs in bytes
| Value | Declared type | Bytes per value | Behaviour at scale |
|---|---|---|---|
| payment_id | BIGINT | 8 | Fixed width; compares in one instruction |
| payment_ref from the new ledger | VARCHAR(32) | Length plus a 1 to 4 byte header | Variable width; compared byte by byte |
| Request id | Native UUID | 16 | Fixed width |
| Request id as text | CHAR(36) | 36 plus header | More than twice the native key |
| amount, up to 18 digits | DECIMAL(18,4) | 8 | 64-bit integer arithmetic inside |
| amount, 38 digits | DECIMAL(38,4) | 16 | 128-bit arithmetic in most engines |
| created_at | TIMESTAMP | 8 | Sorts and prunes by time |
| created_at as ISO text | VARCHAR | 19 to 32 | Sorts by characters |
Decimal width follows the same logic. Parquet stores a DECIMAL with precision up to 18 as a plain INT64, and Spark keeps such a value in a single 64-bit long during arithmetic. Declare DECIMAL(38,4) "to be safe" and every value takes 16 bytes and 128-bit arithmetic, on every row of every scan, for headroom the amount column will never use.
Where width is paid: the hash join
A mismatched key costs more than a wide one. Join payments.payment_id BIGINT to ledger_entries.payment_ref VARCHAR(32) and the engine has to convert one side on every row before it can hash anything. That conversion can also disable the runtime filters and dynamic partition pruning that let the probe side skip files, because the filter was built over one type and the scan reads another.
Text breaks the pruning statistics
- Join keys on fact tables of hundreds of millions of rows or more
- Partition keys, and the sort or clustering keys that decide what gets pruned
- GROUP BY keys in heavy aggregations, held in hash tables like join keys
- Money and counters, where the type sets the overflow ceiling
- Descriptive columns on a 2,000-row dimension
- Low-cardinality strings that dictionary-encode to a few bits
- SMALLINT against INT on columns that only ever appear in the SELECT list
- Free text that is only ever displayed
Which side gets converted
| Engine | Text column = number | Number column = text |
|---|---|---|
| SQL Server | Column converted to INT, which outranks VARCHAR: index scan, and any non-numeric row raises a conversion error | Literal converted to INT: the seek survives |
| MySQL | Both sides compared as DOUBLE: the text column's index is unusable | Literal converted: index used |
| PostgreSQL | Rejected with operator does not exist: character varying = integer | Untyped literal resolved as integer: index used |
PostgreSQL's own trap runs the other way. WHERE payment_id = 880412.0 compares a BIGINT column with a NUMERIC literal. No index operator pairs those 2 types, so the planner casts the column to NUMERIC and the B-tree on payment_id goes unused. The explicit version, created_at::date = '2024-03-15' against an index on a timestamp, fails the same way, and the rewrite is a half-open range: created_at >= '2024-03-15' AND created_at < '2024-03-16'.
The driver nobody suspects
The most common production case never appears in the SQL. Microsoft's JDBC driver sends Java strings as NVARCHAR by default (sendStringParametersAsUnicode=true). NVARCHAR outranks VARCHAR, so a lookup on a VARCHAR column converts the column on every row. Under a SQL collation the seek becomes a scan; under a Windows collation the optimizer can still seek over a computed range, with a worse row estimate. The fix is a single connection-string flag, and the only way to find it is to read the plan.
Reading it in the plan
The rewrite that changes the answer
The reflexive fix is to match the literal to the column: payment_ref = '880412' in place of = 880412. The seek comes back, and so does a different result set, because the numeric comparison matched every spelling of the number and the text comparison matches exactly one. Watch both comparisons evaluate over 6 ledger rows.
SELECTentry_id,payment_ref,payment_ref = '880412' AS text_match,CAST(payment_ref AS BIGINT) = 880412 AS numeric_matchFROM ledger_entries
MySQL's DOUBLE path has a second edge. A double holds every integer exactly only up to a limit, and past it the conversion rounds, so payment_ref = 18014398509481985 matches a row holding '18014398509481984'. On 17-digit ids, comparing text to a number is a correctness bug as well as a scan.
4 fixes and what each costs
| Fix | Restores the seek | What it costs |
|---|---|---|
| Match the literal or parameter to the column's type | Yes | Changes results when spellings differ; count disagreements first |
| Expression index on CAST(payment_ref AS BIGINT) | Only for queries written with that exact cast | Build fails on the first uncastable row; every insert pays for it |
| Persisted computed column with an index (SQL Server) | Yes, if queries use the column | Schema change on a hot table; same uncastable-row problem |
| Change the column's type | Yes, for every reader | A live migration with readers to coordinate |
The result type sets the ceiling
| Engine | SUM over INT | SUM over BIGINT | When the result overflows |
|---|---|---|---|
| PostgreSQL | BIGINT | NUMERIC | Practically never for sums |
| SQL Server | INT | BIGINT | Error 8115, arithmetic overflow |
| Snowflake | NUMBER(38,0) | NUMBER(38,0) | Error past 38 digits |
| BigQuery | INT64 | INT64 | Query fails with an overflow error |
| MySQL | DECIMAL | DECIMAL | Exact; no practical ceiling |
| Spark 3.x, ANSI off | BIGINT | BIGINT | Wraps to a negative number, job succeeds |
Overflow happens before the SUM
The aggregate's type is half the story. In SUM(quantity * unit_price_minor) the multiplication runs per row, in the operands' type. The product of 2 INT columns is an INT, so a single line of 30,000 units at 90,000 minor units, a product of 2.7 billion, overflows before the sum ever sees it. Casting the SUM's result changes nothing; the widening has to reach an operand.
Floats give 2 answers to 1 question
Decimal arithmetic quietly drops scale
DECIMAL stays exact until an expression's result type outgrows 38 digits. Multiplying DECIMAL(p1,s1) by DECIMAL(p2,s2) yields precision p1 + p2 + 1 and scale s1 + s2. Past 38 digits, SQL Server cuts the scale, keeping at least 6 places, and Spark does the same when spark.sql.decimalOperations.allowPrecisionLoss is on, which it is by default. Convert DECIMAL(38,10) amounts with DECIMAL(38,10) rates and every product lands at scale 6, rounded row by row before the sum adds the rounding up. In Spark with ANSI mode off, a decimal product that overflows becomes NULL, which SUM then skips, and a SUM that overflows its own precision returns NULL. Neither raises an error.
3 kinds of change
| Change | PostgreSQL | Iceberg | Snowflake |
|---|---|---|---|
| Lengthen VARCHAR | Catalog only | Not applicable: strings are unbounded | Catalog only |
| Raise DECIMAL precision, same scale | Catalog only | Catalog only | Catalog only |
| INT to BIGINT | Full rewrite under ACCESS EXCLUSIVE lock | Catalog only; old files read as BIGINT | No-op: integers are all NUMBER(38,0) |
| FLOAT to DOUBLE | Full rewrite | Catalog only | No-op: FLOAT is already 64-bit |
| Change DECIMAL scale | Full rewrite | Rejected | Not supported in place |
| Text to number, or any narrowing | Rewrite that fails on the first bad value | Rejected | Not supported in place |
Safe for the table, unsafe for its readers
The precise vocabulary comes from schema registries. INT to LONG is a BACKWARD-compatible Avro promotion: a reader on the new schema decodes old records. FORWARD compatibility fails: a reader still on the old schema cannot decode the new ones. That fixes the order of operations: upgrade every reader first, then let the producer write the wider type. Column-level lineage from dbt's manifest or OpenLineage supplies the list of readers, and the warehouse's query history catches the ones lineage misses.
Changes that alter meaning
Some changes run cleanly and corrupt data. In PostgreSQL, ALTER COLUMN created_at TYPE timestamptz interprets every existing value in the session's TimeZone setting, so a migration run from a laptop set to America/Los_Angeles shifts 2 years of UTC events by 7 or 8 hours. Converting text to integer erases leading zeros from codes that were never numbers, the same padded refs that split the reconciliation. Lowering a DECIMAL's scale rounds every stored value. For each, the question to ask is what an existing value means after the change, and who signed off on that meaning.
Expand and contract for the breaking ones
| Step | What runs | Rollback |
|---|---|---|
| 1. Expand | Add order_id_v2 BIGINT, nullable: a catalog-only change | Drop the column |
| 2. Dual-write | A trigger or the application writes both columns | Stop writing v2 |
| 3. Backfill | Copy in batches of about 50,000 rows by key range, throttled on replica lag | Stop the job |
| 4. Verify | Rows where order_id_v2 IS DISTINCT FROM order_id must number 0 | Fix and re-run |
| 5. Switch readers | Views, ORMs and downstream jobs move to v2, one at a time | Point back at the old column |
| 6. Contract | Stop writing the old column, drop it after the deprecation window | None: the one-way step |
The backfill is batched and throttled because a single UPDATE over 3 billion rows is 1 transaction that bloats the table and holds back vacuum, and replicas stall behind it. The primary key swap is where plans most often bring back the outage. A primary key requires NOT NULL, and setting it on the nullable order_id_v2 scans all 3 billion rows under an ACCESS EXCLUSIVE lock. Instead, add CHECK (order_id_v2 IS NOT NULL) NOT VALID, then run VALIDATE CONSTRAINT, which scans under a lock that lets reads and writes continue. On PostgreSQL 12 and later, SET NOT NULL then trusts that check and skips the scan. Build the unique index with CREATE INDEX CONCURRENTLY. Then, in one short transaction, drop the old primary key and the foreign keys that reference it, and promote the index with ADD CONSTRAINT ... PRIMARY KEY USING INDEX. Recreate those foreign keys against order_id_v2 as NOT VALID, and validate them afterwards outside the lock.
Where the bug was born
Every hop has its own type system. A type bug fixed at the connector is fixed for every node to its right; a cast inside a dashboard fixes one consumer.
A common birthplace is the connector. Debezium's decimal.handling.mode decides how a PostgreSQL NUMERIC travels: precise sends an exact decimal and string sends text. double sends a binary float, which reintroduces every rounding problem the source column was declared to avoid. A string amount in a warehouse is very often a connector setting chosen once, years ago, to make a deserializer error go away. Fixing it there corrects every node downstream at once.
The decision, situation by situation
| Situation | Choice | Why |
|---|---|---|
| 1 reader, 1 analysis, every value casts cleanly | Query-side cast | The blast radius is one query; a source change costs more than it saves |
| Many readers, each writing its own cast | Move the cast into one staging model now, fix upstream next | 4 readers write 4 casts: a TRY_CAST, a CAST, a cast to FLOAT and a forgotten one |
| The cast sits on a join key or hot filter | Upstream | Every query pays a per-row conversion and loses index seeks and pruning |
| Bad values exist and TRY_CAST hides them | Upstream, with bad rows quarantined | A NULL from TRY_CAST is data loss nobody sees |
| A vendor feed you do not control | Staging cast with a reject count | There is no upstream to fix; your staging model is the contract |
| An incident in progress | Query-side cast tonight, upstream ticket with a date | Restore the number first, then remove the cause |
The upstream fix then lands as an enforced contract. A dbt model contract declares each output column's data_type and fails the build when the model produces anything else; a schema registry rejects an incompatible producer schema at write time. Either one turns the next type drift into a failed deploy instead of a wrong dashboard.
Overreach in both directions
- Ask what type each side of a join or filter holds before tuning compute
- Count rows where 2 predicates disagree before moving a cast
- Widen an operand inside SUM, and use COUNT_BIG on large SQL Server tables
- Upgrade every reader before a producer writes a wider type
- Keep each query-side cast in 1 staging model with a reject count
- Land join keys as text because the source sent text, and the same goes for dates and money
- Sum money in FLOAT or DOUBLE and expect 2 runs to agree
- Run INT to BIGINT as one ALTER on a large PostgreSQL table taking writes
- Let TRY_CAST turn bad values into NULLs nobody counts
- Demand a source fix for a one-off analysis over a feed you do not own
> A payments company's nightly reconciliation of payments against ledger_entries, 2.4 billion rows a side, slows from 12 minutes to 3 hours 10 minutes after the ledger team's service migration rewrites payment_ref from BIGINT to VARCHAR(32). The migration also updated a shared join macro to compare CAST(payment_id AS VARCHAR(32)) = payment_ref, so the job's own SQL file never changed, and finance reports a few thousand ledger entries that no longer match any payment.
Type choices decide join speed, index use, the size of totals and how safely a live column can change.
- Category
- SQL
- Difficulty
- advanced
- Duration
- 25 minutes
- Challenges
- 2 hands-on challenges
Topics covered: Storage Types and Query Speed, Implicit Casts That Defeat an Index, Overflow and Precision Loss, Changing a Column's Type in Place, Upstream Fix vs Query-Side Cast
Lesson Sections
- Storage Types and Query Speed (concepts: Basic Types, Storage Optimization, Numeric Precision, Date vs Timestamp)
Nobody asks about storage implications directly. The question arrives as a join that slowed down after a migration, as "we store every id as a string, is that a problem?", or inside a schema design prompt where the interviewer watches what you pick for the key columns. The strong answer names the short list of places where a type's width costs real time, and says where it costs nothing. What a value costs in bytes On disk, columnar formats close some of these gaps. Parquet and ORC dictionary-enc
- Implicit Casts That Defeat an Index (concepts: Type Casting, Basic Types)
A B-tree index keeps the column's values sorted under the column's own type and collation, so it can serve a predicate on the bare column and nothing else. Wrap the column in a function and the order of the index stops matching the order of the expression, so the engine scans. An implicit cast is a function the engine inserted on your behalf, and that's why the bug reaches production: the SQL text looks sargable, and the plan says otherwise. Which side gets converted Every engine resolves a mixe
- Overflow and Precision Loss (concepts: Sums & Averages, Counting, Numeric Precision, Type Casting)
The question rarely says overflow. It arrives as "the revenue total differs between 2 runs of the same query", "finance says the daily sum is 3 cents off", or "a job that has run for a year failed last night with an arithmetic error". Each is a type question, and the first move is to ask what type the aggregate returns in this engine, because the result type is where the ceiling sits. The result type sets the ceiling SQL Server is where billions of rows bite first: SUM over an INT column returns
- Changing a Column's Type in Place (concepts: Basic Types, Numeric Precision)
The prompt is usually a requirement with a deadline attached: "order_id is about to pass the INT limit, widen it to BIGINT, the table takes writes around the clock", or "product needs 4 decimal places on amount". The interviewer is listening for a classification before a plan. Some type changes are a catalog edit that finishes in milliseconds, some rewrite every row under a lock, and some change what the existing values mean. 3 kinds of change PostgreSQL rewrites the table and rebuilds every ind
- Upstream Fix vs Query-Side Cast (concepts: Type Casting, Safe Casting, Null Handling)
The interview version: "An analyst found that amount lands in the warehouse as a string, so they added CAST(amount AS DECIMAL(20,4)) to their dashboard query. Ship it, or fix it upstream?" Either answer can be right. What gets graded is whether the choice follows from the situation, and whether the candidate notices that a cast inside one dashboard fixes the type for exactly one reader. Where the bug was born The decision, situation by situation The second row is where most strong answers land: