Data Types: Advanced
| Type | Bytes per value | What it holds |
|---|---|---|
| TINYINT | 1 | Integers from -128 to 127 |
| SMALLINT | 2 | Integers from -32,768 to 32,767 |
| INTEGER | 4 | Integers up to about 2.1 billion either way |
| BIGINT | 8 | Integers up to about 9.2 quintillion either way |
| REAL | 4 | Floating point, about 7 significant digits |
| DOUBLE | 8 | Floating point, about 15 significant digits |
| DECIMAL(p,s), p up to 18 | 8 | Exact decimals |
| DECIMAL(p,s), p 19 to 38 | 16 | Exact decimals, wider |
| DATE | 4 | A calendar day |
| TIMESTAMP | 8 | A date and time to the millisecond |
| BOOLEAN | 1 | true or false |
These are the widths the engine uses for each value in memory. Files on disk usually hold less, which the compression section covers.
Size each column to its worst case
Pick the narrowest type that holds the largest value the column could ever take, judged by what the value measures. watch_ms in the Reels view log is a single view's watch time in milliseconds. SMALLINT stops at 32,767, under 33 seconds, so the first long view breaks it. INTEGER reaches 2,147,483,647 ms, almost 25 days, far past any single view, so it fits with room to spare, while BIGINT would spend 4 more bytes per row on range no view will use. IDs are the exception: generators at this scale hand out 64-bit values, and a narrower column fails the first time an ID passes its ceiling, so IDs get BIGINT.
- ▸Floating types for IDs: DOUBLE holds every integer exactly only up to 9,007,199,254,740,992, and REAL only up to 16,777,216.
- ▸Running totals in INTEGER: a single view's milliseconds fit, while a reel's lifetime watch time passes 2,147,483,647 soon after launch.
- ▸Numbers and dates as VARCHAR: values take more bytes and sort as text. Any query that does math on them needs a CAST first.
Text that holds numbers sorts as text
Raw logs often land with every field as text. Keeping watch_ms as VARCHAR costs more bytes than INTEGER and changes how values compare: text is compared character by character, so '15000' sorts ahead of '800' because '1' comes before '8'. Predict the order in each tab before you run it.
A video_stats table keeps a row per reel with lifetime_watch_ms, the total milliseconds all viewers have spent on that reel. Which type should the column use?
Price one row of the Reels view log
| Column | Type | Bytes per value |
|---|---|---|
| view_id | BIGINT | 8 |
| video_id | BIGINT | 8 |
| user_id | BIGINT | 8 |
| viewed_at | TIMESTAMP | 8 |
| watch_ms | INTEGER | 4 |
| is_autoplay | BOOLEAN | 1 |
| country | VARCHAR | 6 |
| Row total | 43 |
The country value is a 2-letter code: 2 bytes of text plus 4 bytes recording where the value ends. Variable-width data is commonly laid out with a 4-byte length or offset per value, so 4 bytes is a sound planning figure. Now scale it: suppose the table takes 10 billion rows a day, and run the arithmetic.
2000000000 * 43 an INTEGER product, which overflows and fails the query. Make a factor wider, as 10000000000 above already is, and the whole product is computed as BIGINT.Narrow widths pay off because they multiply. Here is what the 4 bytes INTEGER saves over BIGINT on watch_ms come to at that daily volume.
- Compression, which usually brings files well under the estimate
- Null markers, typically a bit or a byte per value
- Per-file metadata such as column statistics, small beside the data
A VARCHAR is priced in bytes
| Value | Characters | UTF-8 bytes |
|---|---|---|
| US | 2 | 2 |
| Lisbon | 6 | 6 |
| São Paulo | 9 | 10 |
| 東京 | 2 | 6 |
| 🎬 | 1 | 4 |
To price a VARCHAR column, sample it, find the average bytes per value and add the 4-byte length. A city column averaging 9 ASCII characters costs roughly 13 bytes a value; the same length in a script needing 3 bytes per character costs about 31. Storing country as 'United States' rather than 'US' takes it from 6 bytes to 17.
VARCHAR(n) caps length and truncates
A declared length reserves nothing: VARCHAR(255) holding 'US' costs what VARCHAR holding 'US' costs. The limit acts when text is converted. Casting longer text to a bounded VARCHAR keeps the first n characters and raises no error, so a limit set too low corrupts data quietly.
Both results fit their limits. The limit counts characters, so 'São' keeps 3 characters while taking 4 bytes. A pipeline that casts incoming text to VARCHAR(2) would turn a source's 'Brazil' into 'Br', which matches none of the stored codes, without a single error.
A device_model column is declared VARCHAR(255). Its values average 12 ASCII characters across 2 billion rows. Using this section's model, about how large is the decoded column?
Client attributes change faster than schemas. iOS sends app_version and Android adds network. Meanwhile a test build ships flags that nobody will read next month. A column per attribute means a schema change for each new key and rows that are NULL in most columns. A MAP column holds a set of key-value pairs on each row, so every row carries exactly the keys it has.
MAP(VARCHAR, VARCHAR) names a key type and a value type. All keys in a map share the first, all values share the second, and a key appears at most once per map. You build a map from 2 arrays whose positions pair up: MAP(ARRAY['os', 'network'], ARRAY['android', 'wifi']) holds os as android and network as wifi.
Subscript or element_at
A key can be read 2 ways, and they part company on a missing key. The subscript client_props['os'] fails the whole query when any row lacks the key. element_at(client_props, 'network') returns NULL for that row and carries on. Predict which views get a NULL network, then run it.
Views 101 and 103 send no network key, so element_at returns NULL for them. Written as client_props['network'], the same query would fail on the first view lacking the key with a key-not-present error, which is why subscripts belong only on keys every row is guaranteed to carry, such as os here. cardinality counts the pairs in each map and map_keys lists their keys, a fast way to see which attributes each client sends.
For a row whose client_props holds only the key os, what does buffer_ms come out as?
The price of flexibility
- A new key needs no schema change
- Every row stores its key names, so app_version costs 11 bytes plus a length on each row
- Values share a single type: a number arrives as text and needs a CAST
- A misspelled key read with element_at returns NULL, with no error
- Each new attribute is a schema change
- The name lives in the schema, so rows hold values only
- Each column has its own type, checked when data is written
- A misspelled column fails the query at once
The second row of that comparison is a cost you can price with the storage method: the key app_version costs 15 bytes on every row, 11 for the characters and 4 for the length, so at 10 billion rows a day it adds 150 GB of decoded key text daily, before any value is counted. Repeated keys compress well on disk, but every query that opens the map handles them again.
os and app_version that nearly every query reads, and leaves the long tail of rare or short-lived keys in a single MAP.The Reels client team sends more attributes every release. Almost every dashboard reads `os`, and the mobile team adds 2 or 3 short-lived experiment keys a month. Decide where each kind of attribute lives.
| view_id BIGINT | video_id BIGINT | watch_ms INTEGER | viewed_at TIMESTAMP |
|---|
Where do `os`, `app_version` and the experiment keys go?
A MAP unnests into key and value
Pass UNNEST a map and it returns a row for each key-value pair, as 2 columns you name in the alias. Each output row keeps its parent's columns, so view_id repeats for every pair. Predict how many rows view 203 produces before you run it.
ROW fields and arrays of records
A ROW bundles named fields of different types into a single value, much like a small record. Read a field with a dot: device.os. An ARRAY of rows is the common shape for a list of records, such as the reels watched in a session, and UNNEST spreads each record's fields into separate columns. Adding WITH ORDINALITY appends a further column holding each element's position, counted from 1.
Session s-1 becomes 3 rows, a row per reel, each carrying its session's os. The watch_order column preserves viewing order, which matters because SQL guarantees no row order without ORDER BY, and nothing else in the unnested rows records it.
Predict the row count before trusting it
UNNEST yields a row for each element, so the output holds as many rows as all the arrays combined, with every parent column repeated on each. A session with 50 reels repeats its session_id and device 50 times, which adds up before a large unnest. A parent whose array is empty or NULL produces no rows at all, so it drops out of the result.
Video 9002 has an empty hashtags array, so it's absent from the output. When every parent row must appear, this pattern loses rows you need; an outer form that keeps them arrives with the outer join lessons.
cardinality works on arrays as well as maps, so cardinality(reels) read on the parent rows shows how many rows each parent will contribute to an UNNEST of reels, before you run it.How many rows does this query return?
The alias after UNNEST names every column it produces, so the number of names is fixed by what is being expanded: 1 for an array of plain values, 2 for a map's keys and values, 1 per field for an array of rows, and 1 more with WITH ORDINALITY. A name list that does not match the column count fails the query before any row is read.
> Using reel_views, whose client_props column is a MAP(VARCHAR, VARCHAR), produce a row per attribute with the columns view_id, prop_key and prop_value, ordered by view. Two tiles do not belong.
The disk holds less than your estimate
| row | country |
|---|---|
| 1 | US |
| 2 | US |
| 3 | BR |
| 4 | US |
| 5 | IN |
| 6 | BR |
| code | country |
|---|---|
| 0 | US |
| 1 | BR |
| 2 | IN |
Against that dictionary, the 6 rows are stored as the codes 0, 0, 1, 0, 2, 1. Sort the rows by country before writing and the codes form 3 runs, one per country. US covers 3 rows and BR 2, leaving a single IN, so sort order is a storage decision too. Suppose a day of reel_views holds 200 distinct countries: a single byte holds 256 different codes, so every row's country shrinks to 1 byte.
Writing groups each column's values, encodes repeats with dictionaries and runs, then compresses each block. A query reverses those steps, and once values are decoded they cost their full type width again.
Values that do not fit their type
SELECTview_id,watch_ms_text,TRY_CAST(watch_ms_text AS INTEGER) AS watch_ms_int,TRY_CAST(watch_ms_text AS BIGINT) AS watch_ms_bigFROM raw_watch
TRY covers more than casts
TRY(expression) evaluates any expression and returns NULL when it raises one of these errors: a failed cast, a numeric overflow, a division by zero, or an invalid function argument such as a missing map key. Predict all 5 columns, then check your answer against the paragraph under the query.
- Keep the raw text column beside every value you parse with TRY_CAST
- Widen the type when overflows recur, then remove the TRY around it
- Use element_at for optional map keys and subscripts for guaranteed ones
- Sort files on repetitive columns before writing so runs form
- Wrap expressions in TRY to make a failing query go quiet
- Read a NULL from TRY_CAST as proof that the source sent nothing
- Store numbers or dates as VARCHAR to dodge parse failures at load
- Assume compression removes the cost of a wide type at query time
A change meant to save space shipped this week. Since then the Reels dashboard shows no views longer than roughly 33 seconds, and no query has failed. Here is the new parse step. What happened?
> Picture Meta's Reels team asking for a daily table of watch time per reel and client platform, fed by a raw log where watch time arrives as text and client attributes arrive as a MAP. A single pipeline has to size the table, parse the text, pull attributes out of the map and keep bad values in view.
watch_ms to INTEGER and the view and video IDs to BIGINT, and the storage estimate prices a day of rows before anything is written.os key moves out of the MAP into a typed column because every report reads it, while short-lived experiment flags stay in the map.watch_ms_text into INTEGER with the raw text kept alongside, so a type set too narrow or a malformed client shows up as visible NULLs.Choose types that fit billions of rows, price their bytes, query maps and nested arrays, and survive bad values.
Arrays, maps, and type optimization at scale
- Category
- SQL
- Difficulty
- advanced
- Duration
- 21 minutes
- Challenges
- 12 hands-on challenges
Topics covered: Type optimization at scale, Storage calculations and VARCHAR, MAP data type for key-value pairs, Accessing nested data (UNNEST), Compression and error handling
Lesson Sections
- Type optimization at scale (concepts: Storage Optimization)
A column's type fixes how many bytes each value occupies once the engine reads it, and that width is paid on every row. At 10 billion rows, 1 extra byte per row is 10 GB more that the engine scans and holds in memory on every query that touches the column, and that data also travels between machines. The table below lists the widths you are choosing between. These are the widths the engine uses for each value in memory. Files on disk usually hold less, which the compression section covers. Size
- Storage calculations and VARCHAR (concepts: Storage Optimization)
An estimate needs 2 numbers: how many rows, and how many bytes per row. Bytes per row is the sum of the fixed widths from the type table plus the average size of each VARCHAR. The product is the decoded size, the bytes a query works through after it reads the files. Compression makes files smaller, so treat the figure as a ceiling for storage and as the working size whenever a query scans or sorts the data. Rows sent between machines travel at that size too. Price one row of the Reels view log A
- MAP data type for key-value pairs (concepts: Maps)
Subscript or element_at The price of flexibility
- Accessing nested data (UNNEST) (concepts: Array Flattening)
Nested types keep related values inside a single row, which is compact to store but awkward to query, since ORDER BY and CAST, like every other expression you know, operate on rows and columns. UNNEST converts the nested shape into the flat one. You have used it on a plain array; the same operator also expands maps and arrays of records, and each kind returns a different set of columns. A MAP unnests into key and value ROW fields and arrays of records Predict the row count before trusting it
- Compression and error handling (concepts: Safe Casting)
The disk holds less than your estimate Columnar files store each column's values together, and values within a column tend to repeat: the same countries, the same map keys, timestamps milliseconds apart. Encoders exploit that before a general-purpose compressor runs. Dictionary encoding writes each distinct value once and replaces every occurrence with a small integer code. Run-length encoding replaces a run of identical values with a single value and a count. The decoded estimate still governs