3.4 Schema Definition & Evolution for Loaded Data
Key Takeaways
Avro, Parquet, and ORC files carry their own schema; CSV and JSON loads should use a version-controlled JSON schema file rather than autodetect, which samples only up to 500 rows.
BigQuery column modes are NULLABLE (default), REQUIRED, and REPEATED; a RECORD in REPEATED mode stores an array of structs such as order line items.
Appends that fail because the source gained a column are fixed with the ALLOW_FIELD_ADDITION schema update option, not by rebuilding the table.
Adding a field inside an existing RECORD requires a JSON schema file with bq update or the API; SQL DDL and the console cannot add nested fields.
You can relax REQUIRED to NULLABLE, but BigQuery cannot tighten an existing NULLABLE column to REQUIRED.
3.4 Schema Definition & Evolution for Loaded Data
Core Focus: Every load job needs a schema, and every production table eventually needs a schema change. The exam tests whether you know where a schema comes from (self-describing files, a schema file, or autodetection), how nested and repeated data maps into BigQuery, and which schema changes BigQuery allows without rebuilding a table.
Section 3.1 covered how data gets into BigQuery. This section covers what shape it takes when it arrives and how that shape changes safely over time. Querying nested data with UNNEST and JSON functions is covered in Section 4.2.
1. Four Ways to Supply a Schema
| Approach | How it works | Best fit | Risk |
|---|---|---|---|
| Self-describing files | Avro, Parquet, ORC, and Firestore/Datastore exports carry their own schema; BigQuery reads it | Most production loads | Upstream schema changes arrive automatically, so pair with schema update options |
| JSON schema file | A file listing each column's name, type, mode, and optional description | CSV and JSON loads that need exact types | The file must be kept in version control and updated with the source |
| Inline schema | --schema=name:STRING,age:INT64 on the bq command line | Quick, flat tables | Cannot set descriptions, modes, or nested RECORD columns |
| Autodetect | BigQuery picks a file, samples up to the first 500 rows, and infers names and types | Exploration and one-off loads | Wrong guesses (for example, a ZIP code inferred as INT64 loses leading zeros) |
A JSON schema file for an orders table with nested and repeated data looks like this:
[
{"name": "order_id", "type": "STRING", "mode": "REQUIRED", "description": "Order UUID"},
{"name": "customer_id", "type": "STRING", "mode": "NULLABLE"},
{"name": "order_ts", "type": "TIMESTAMP", "mode": "REQUIRED"},
{"name": "items", "type": "RECORD", "mode": "REPEATED", "fields": [
{"name": "sku", "type": "STRING", "mode": "NULLABLE"},
{"name": "quantity", "type": "INT64", "mode": "NULLABLE"},
{"name": "unit_price", "type": "NUMERIC", "mode": "NULLABLE"}
]}
]
bq load --source_format=NEWLINE_DELIMITED_JSON \
--schema=./schemas/orders.json \
retail_dw.orders gs://landing/orders/2026-10-08/*.json
2. Column Modes and Nested Data
Every column has one of three modes:
- NULLABLE (the default): the value may be missing.
- REQUIRED: a missing value fails the load or insert.
- REPEATED: the column holds an array of values of one type.
A RECORD (shown as STRUCT in SQL) groups related fields, such as an address. A RECORD in REPEATED mode is an array of structs, such as the line items of an order. BigQuery maps source data as follows:
| Source structure | BigQuery result |
|---|---|
| JSON nested object, Avro record, Parquet group | RECORD / STRUCT column |
| JSON array, Avro array, Parquet repeated field | REPEATED column (ARRAY) |
| Array of objects | REPEATED RECORD (ARRAY<STRUCT<...>>) |
| CSV | Flat columns only; CSV cannot carry nested data |
Nested and repeated fields let one row hold an entity with its children, which avoids large joins at query time. You can also define them with DDL. Note that clustering columns must be top-level, non-repeated columns, so keep a top-level copy of any key you want to cluster on:
CREATE TABLE `retail_dw.orders` (
order_id STRING NOT NULL,
customer_id STRING, -- top-level copy used for clustering
order_ts TIMESTAMP NOT NULL,
customer STRUCT<email STRING, loyalty_tier STRING>,
items ARRAY<STRUCT<sku STRING, quantity INT64, unit_price NUMERIC>>
)
PARTITION BY DATE(order_ts)
CLUSTER BY customer_id;
3. Evolving a Schema Safely
Sources change. The question is which changes BigQuery accepts in place:
| Change | Supported? | How |
|---|---|---|
| Add a new NULLABLE top-level column | Yes | ALTER TABLE ... ADD COLUMN, bq update with a schema file, or a load/query job with ALLOW_FIELD_ADDITION |
| Add a field inside an existing RECORD | Yes, but not with SQL DDL or the console | bq update with a JSON schema file (new field appended to the RECORD's fields), or the API's tables.patch |
| Relax REQUIRED to NULLABLE | Yes | ALTER TABLE ... ALTER COLUMN ... DROP NOT NULL, or ALLOW_FIELD_RELAXATION on a load/query job |
| Rename a top-level column | Yes | ALTER TABLE ... RENAME COLUMN old TO new |
| Drop a column | Yes | ALTER TABLE ... DROP COLUMN (a metadata change; storage is not rewritten immediately) |
| Change a data type | Only some widening conversions | ALTER COLUMN ... SET DATA TYPE (for example INT64 to NUMERIC); otherwise add a new column and backfill, or recreate the table |
| Tighten NULLABLE to REQUIRED | No | Recreate the table with the new schema |
The most common exam scenario is an append that fails because the source gained a column. Instead of recreating the table, add a schema update option to the job:
# Avro files now contain a new optional field; keep appending without a rebuild
bq load --source_format=AVRO \
--write_disposition=WRITE_APPEND \
--schema_update_option=ALLOW_FIELD_ADDITION \
retail_dw.orders gs://landing/orders/2026-10-09/*.avro
-- DDL alternatives for top-level changes
ALTER TABLE `retail_dw.orders` ADD COLUMN discount_code STRING;
ALTER TABLE `retail_dw.orders` RENAME COLUMN discount_code TO promo_code;
ALTER TABLE `retail_dw.orders` ALTER COLUMN order_ts DROP NOT NULL;
4. Semi-Structured Payloads: Typed Columns or the JSON Type?
When a payload's fields are unpredictable (third-party webhooks, polymorphic events), you can load it into a single column of BigQuery's native JSON type. BigQuery parses it at ingestion into an efficient internal format, and new keys need no schema change. When the structure is known and stable, typed STRUCT and ARRAY columns give stricter validation, better compression, and faster queries.
| Criterion | Typed STRUCT/ARRAY columns | Native JSON column |
|---|---|---|
| Schema enforcement | At load time (schema-on-write) | At query time (schema-on-read) |
| New source fields | Need a schema update | Stored immediately |
| Query performance | Best | Good, slightly slower |
| Typical use | Core facts and dimensions | Webhooks, logs, vendor payloads |
A common pattern combines both: typed columns for the fields you report on, plus a JSON column that keeps the full raw payload for later.
Common Exam Traps
- Autodetect in production: Autodetect samples a few hundred rows, so it can mistype identifiers or miss columns that appear later. Production loads should use self-describing formats or a version-controlled schema file.
- Rebuilding a table for a new column: If an append fails because the source added a field, use
ALLOW_FIELD_ADDITION(orADD COLUMN) rather than truncating and reloading history. - Using DDL to add a nested field:
ALTER TABLE ... ADD COLUMNadds top-level columns only. A new field inside a RECORD needs a JSON schema file withbq updateor the API. - Tightening modes: You can relax REQUIRED to NULLABLE, but you cannot change NULLABLE to REQUIRED on an existing table.
A nightly job appends Avro files to an existing BigQuery table. The source team added a new optional field to the Avro schema, and tonight's append fails with a schema mismatch. The team must keep all existing rows and keep loading. What should the data practitioner change?
Raise --max_bad_records so rows containing the new field are skipped
Convert the Avro files to CSV and load them with --autodetect
Add --schema_update_option=ALLOW_FIELD_ADDITION to the append load job
Switch the job to WRITE_TRUNCATE and reload the full history every night
A table has a RECORD column named shipping_address. The team must add a new nested field, delivery_instructions STRING, inside that RECORD without losing data. Which approach works?
Add the field to the RECORD's fields in a JSON schema file and apply it with bq update
Drop the shipping_address column and add it back as a STRUCT that includes the new field
Run ALTER TABLE ... ADD COLUMN shipping_address.delivery_instructions STRING as a DDL statement
Open the console's schema editor and use Add field under the existing RECORD column
A data engineering team ingests clickstream events containing unpredictably varying payload attributes from multiple third-party web tracking providers. They need to choose between storing the event payload as an ARRAY<STRUCT<...>>, a raw STRING, or the native BigQuery JSON data type. Why is the native JSON data type the recommended choice for this specific workload?
The native JSON data type supports Hive partitioning and automatic clustering on all subfields
The native JSON data type enforces strict static type checking and fails ingestion if new fields are detected
It accepts unpredictable keys without schema changes and stores them in an efficient binary format
The native JSON data type is stored as plain uncompressed ASCII text, making string regex matching faster
Sections you finish are checked off in the contents.