datarekha

Schemas — inferred vs explicit

Letting Spark infer a schema is convenient. It's also a slow, sometimes-wrong default. Explicit StructTypes are the production answer.

6 min read Intermediate PySpark Lesson 10 of 22

What you'll learn

  • Why schema inference is slow and risky on big files
  • StructType + StructField + DataType — the building blocks
  • How to handle schema evolution gracefully

Before you start

The last lesson called the DataFrame “typed” and then hurried past where those types come from with a parenthetical: Spark infers them, and for production you should declare them. We flagged the danger — a zip code 01234 silently read as the integer 1234 — and promised the full story. Here it is: the two ways a DataFrame gets its column types, why letting Spark guess is both slow and unsafe, and how to nail the types down yourself.

When you read a CSV or JSON, Spark needs to figure out the schema: “which columns exist, what type is each?” You can let it infer the schema from the data, or you can declare it explicitly. The inferred path is cheaper to write. The explicit path is cheaper to run, harder to break, and the only acceptable choice in production.

How inference works (and why it’s slow)

When you set inferSchema=True on a CSV read:

df = spark.read.option("header", True).option("inferSchema", True).csv("s3://bucket/big.csv")

Spark does this:

  1. Reads the first few rows to find column names
  2. Scans the entire file a second time to determine each column’s type
  3. Then returns a DataFrame referencing the data

That second scan is the cost. On a 100GB CSV, you’ve paid the read cost twice before any of your actual queries run. On a streaming source you’ve usually pre-scanned the input.

JSON is worse: Spark needs to scan everything to find every possible field across all records, since JSON records can have different keys.

Parquet doesn’t have this problem — schemas are stored in the file footer and inferred instantly. Always prefer Parquet to CSV/JSON for new data.

How inference goes wrong

Beyond the speed cost, inference makes mistakes:

ColumnWhat’s in the fileWhat Spark infersWhat you wanted
zip_code01234, 00876IntegerTypeStringType (leading zeros lost!)
id12345, 98765, 1.5e9DoubleTypeLongType
event_at2026-05-28T10:30:00ZStringTypeTimestampType
amount100.50, 200.00, N/AStringTypeDoubleType (or null on N/A)

The zip code case is the classic — Spark sees integers and silently drops the leading zeros, breaking joins downstream.

The StructType building blocks

A Spark schema is a StructType (a named, ordered collection of typed fields — think “struct” as in C/Go). Each StructField has a name, a DataType, and a nullability flag.

from pyspark.sql.types import (
    StructType, StructField,
    StringType, IntegerType, LongType, DoubleType,
    BooleanType, DateType, TimestampType,
    ArrayType, MapType,
)

orders_schema = StructType([
    StructField("order_id",   LongType(),      nullable=False),
    StructField("user_id",    LongType(),      nullable=False),
    StructField("amount",     DoubleType(),    nullable=True),
    StructField("currency",   StringType(),    nullable=True),
    StructField("status",     StringType(),    nullable=False),
    StructField("created_at", TimestampType(), nullable=True),
    StructField("tags",       ArrayType(StringType()), nullable=True),
])

df = spark.read.schema(orders_schema).option("header", True).csv("s3://bucket/orders.csv")

The result:

  • Spark skips the inference pass entirely — because you already told it the types, it never needs to read the file a second time just to guess them
  • Type coercion happens on read; bad rows become null (or fail, depending on mode)
  • Downstream code can rely on the types

The DDL string shorthand also works:

orders_schema = """
  order_id   BIGINT,
  user_id    BIGINT,
  amount     DOUBLE,
  currency   STRING,
  status     STRING,
  created_at TIMESTAMP,
  tags       ARRAY<STRING>
"""
df = spark.read.schema(orders_schema).csv("s3://bucket/orders.csv")

Either form is fine. DDL strings are shorter; programmatic StructType is easier to compose at runtime.

Common DataTypes

The ones you’ll use every day:

TypeExample valueNotes
StringType"Mumbai"Default for text
IntegerType4232-bit; use LongType for IDs
LongType123456789012364-bit; default for any ID
DoubleType3.14159Use DecimalType for money
DecimalType(p, s)123.45Exact decimal; required for finance
BooleanTypeTrue
DateType"2026-05-28"Day-precision
TimestampType"2026-05-28T10:00:00Z"UTC under the hood
ArrayType(T)["a", "b"]List of T
MapType(K, V){"k": "v"}Dict
StructTypenested recordFor nested JSON-shaped data

For money, always use DecimalType. DoubleType introduces floating- point rounding errors that haunt financial reconciliation.

Three modes for handling bad rows

When a row doesn’t conform to the schema, you choose what happens with .option("mode", ...):

ModeBehavior
PERMISSIVE (default)Fields that can’t parse become null; the row survives
DROPMALFORMEDBad rows are silently dropped
FAILFASTJob fails on the first bad row
df = (spark.read
      .schema(orders_schema)
      .option("mode", "FAILFAST")
      .csv("s3://bucket/orders.csv"))

For production pipelines:

  • Bronze (raw landing)PERMISSIVE + capture the raw text in a _corrupt_record column so you can debug later
  • Silver (cleaned)FAILFAST or strict validation; fix the upstream problem
  • Gold (mart) — should never see bad data; it was validated upstream

Inspecting an inferred schema

A common workflow: infer first to see what Spark thinks, then write an explicit schema.

# Inspect an inferred schema, then convert it to DDL you can paste.

# Simulated 'inferred schema' as Spark would produce it
inferred = {
    "type": "struct",
    "fields": [
        {"name": "order_id",   "type": "long",      "nullable": False},
        {"name": "user_id",    "type": "long",      "nullable": False},
        {"name": "amount",     "type": "double",    "nullable": True},
        {"name": "currency",   "type": "string",    "nullable": True},
        {"name": "status",     "type": "string",    "nullable": False},
        {"name": "created_at", "type": "timestamp", "nullable": True},
    ],
}

# Build a DDL string you can paste into your code
ddl_parts = [
    f'{f["name"]:<12s} {f["type"].upper()}'
    for f in inferred["fields"]
]
print("Schema as DDL:")
print(",\n".join("  " + p for p in ddl_parts))
Schema as DDL:
  order_id     LONG,
  user_id      LONG,
  amount       DOUBLE,
  currency     STRING,
  status       STRING,
  created_at   TIMESTAMP

That’s the whole workflow in miniature: take whatever Spark guessed (here a struct dict, in real life df.schema.json()), read it once with human eyes, and freeze it into a DDL string you paste into your code so the guess never runs again. From this point on the read is deterministic — Spark is told order_id is LONG and currency is STRING, rather than re-scanning the file to re-derive that every time. Inference becomes a one-time development convenience, not a permanent runtime tax.

Schema evolution — the production hard part

Source data changes. A field is added. A field becomes optional. A field’s type changes from int to string. Your schema needs to evolve.

Three strategies:

1. Strict — enforce the schema, fail on new fields

Best for fact tables where new fields would silently fall on the floor.

# Old + new files both readable; new columns logged
df = spark.read.schema(orders_schema_v3).csv("s3://bucket/orders/")

2. Permissive merge — Parquet’s mergeSchema

When you write Parquet with different schemas over time, Spark can merge them on read:

df = (spark.read
      .option("mergeSchema", "true")
      .parquet("s3://bucket/orders/"))

Expensive (reads every file’s footer) but useful in dev.

3. Use a table format

Delta Lake, Iceberg, and Hudi all support proper schema evolution: add columns safely, with type-promotion rules, and an audit trail of schema changes. This is the production answer.

# Delta — schema evolution with one option
(df_new.write
   .format("delta")
   .mode("append")
   .option("mergeSchema", "true")
   .save("s3://bucket/orders_delta/"))

In one breath

A Spark schema is a StructType of StructFields (each a name, a DataType, and a nullability flag), and you can let Spark infer it — convenient, but it re-scans the entire CSV/JSON just to guess types (doubling I/O) and guesses wrong in dangerous ways (a zip code 01234 becomes the integer 1234) — or declare it explicitly via StructType or a DDL string, which skips the inference pass and coerces types on read; Parquet sidesteps the whole problem by storing its schema in the footer, money belongs in DecimalType not DoubleType, malformed rows are handled by PERMISSIVE/DROPMALFORMED/FAILFAST modes, and real schema evolution is what pushes teams to Delta or Iceberg.

Practice

Before the quiz, run the inference-failure table in your head: for zip_code values 01234/00876, what type does Spark infer, what did you want, and what exactly breaks downstream? Then make the production call: for a Bronze raw-landing table vs a Silver cleaned table, which bad-row mode fits each and why — and what does pairing PERMISSIVE with a _corrupt_record column buy you that plain DROPMALFORMED does not?

Quick check

0/3
Q1Why is `inferSchema=True` problematic on large CSV files in production?
Q2You're storing money amounts (USD). Which type should you use?
Q3What does PERMISSIVE mode do when a CSV row can't be parsed against your schema?

A question to carry forward

Look closely at why we cared so much about pinning down types — and one column in every schema we wrote gives it away. order_id and user_id were LongType, marked nullable=False, fussed over more than the others. They are the keys, and a key only earns that fussiness when you’re about to match it against another table’s key. A schema in isolation is just a description; it becomes load-bearing the moment two DataFrames have to meet on a shared column.

And that meeting is both the most common thing you’ll do with two DataFrames and — you already know this from the last chapter — one of the most expensive, because matching keys across the cluster means a shuffle. So the question to carry forward is where structure and performance collide: when you take two well-typed DataFrames and combine them on a key, how does Spark actually do it, why is the naive version a full shuffle, and when can a tiny table turn an hour-long join into a one-minute one? That is joins in Spark, and it is the next lesson.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What is the difference between an RDD, a DataFrame, and a Dataset in Spark?

RDD is the low-level, type-safe distributed collection with no schema knowledge. DataFrame adds a named-column schema on top, enabling the Catalyst optimizer and codegen — but loses compile-time type safety. Dataset merges both worlds: it carries a schema and passes through Catalyst while remaining statically typed in Scala/Java.

What is the difference between a star schema and a snowflake schema, and which should you choose?

A star schema has a central fact table joined directly to denormalized dimension tables, giving simple two-table joins and fast query performance at the cost of some data redundancy. A snowflake schema normalizes dimensions into sub-dimension tables, reducing storage and update anomalies but requiring more joins that can slow analytical queries.

What is the difference between a star schema and a snowflake schema in dimensional modeling?

A star schema has a central fact table joined directly to denormalized dimension tables — one join hop per dimension, simple queries, better query performance. A snowflake schema normalizes dimension tables into sub-dimensions, reducing storage redundancy but requiring more joins. Star schemas are preferred for analytics workloads; snowflake schemas are sometimes used when a dimension is very large and has many redundant attribute values.

How do you handle schema evolution in data pipelines without breaking downstream consumers?

Schema evolution covers adding, renaming, removing, or retyping columns in a data stream or table over time. Safe strategies include: only adding nullable columns (backwards-compatible), using schema registries to enforce compatibility rules before a producer publishes, and open table formats like Iceberg that track schema history and allow column renames and reorders without rewriting data.

Related lessons

Explore further

Skip to content