Data schemas change as applications evolve. A sales pipeline may initially store an order ID, customer ID, and order amount. Later, the business may need additional fields such as a discount code, payment method, or delivery status.
Adding a column to a database table is often straightforward. However, changing the schema of a data lake table can be more complicated when multiple jobs read and write the same data. A batch pipeline may expect the old schema, while a streaming job begins producing records with new fields.
If these changes are not managed carefully, pipelines can fail, downstream tables can become inconsistent, and dashboards may stop updating.
Delta Lake provides schema evolution capabilities that allow supported schema changes to be applied during data writes. When configured correctly, these capabilities can help teams introduce new columns without unnecessarily disrupting their pipelines.
In this article, you will learn how Delta Lake schema evolution works, how to add columns safely, how to handle streaming jobs, and how to avoid common compatibility problems.
What Is Schema Evolution in Delta Lake?
Delta Lake is a storage framework that adds transactional capabilities and table management features to data lakes, commonly using Parquet files and a transaction log.
A Delta table has a schema that describes its columns and data types. For example, an orders table might initially use this schema:
Column | Data type |
|---|
order_id
| STRING |
customer_id
| STRING |
amount
| DECIMAL |
As the application develops, the business may request a new column called payment_method.
Schema evolution is the process of allowing a supported schema change, such as adding this column, while maintaining the table's consistency.
Delta Lake can support schema evolution during writes, but the exact configuration depends on the operation and the Spark and Delta Lake versions in use.
Schema evolution does not automatically make every reader, transformation, or streaming query compatible with the new schema. Those components must still be designed to handle changes appropriately.
Why Schema Changes Can Break Streaming Pipelines
Consider a streaming pipeline that reads order events from an upstream system and writes them to a Delta table.
Initially, the events contain:
{
"order_id": "ORD-1001",
"customer_id": "CUST-101",
"amount": 250.00
}
Later, the upstream application adds a payment_method field:
{
"order_id": "ORD-1002",
"customer_id": "CUST-102",
"amount": 450.00,
"payment_method": "UPI"
}
The new event contains an additional field, but existing jobs may still expect the original schema.
Several problems can occur:
A writer may reject the incoming schema because it does not match the target table.
A transformation may reference a field that is not present in every input.
A streaming query may need to restart or reprocess data after a schema change.
A downstream consumer may assume a fixed number of columns.
An incompatible type change may cause data conversion errors.
The solution is not to enable schema evolution indiscriminately. A safer approach is to identify the intended schema change, configure the write operation, validate downstream compatibility, and deploy the change in a controlled order.
Step 1: Create a Delta Table
The following examples use PySpark with Delta Lake. They assume that Spark and Delta Lake are already configured in the environment.
Create a small DataFrame representing order data:
from decimal import Decimal
from pyspark.sql import SparkSession
from pyspark.sql.types import (
StructType,
StructField,
StringType,
DecimalType,
)
spark = SparkSession.builder.appName("DeltaSchemaEvolution").getOrCreate()
schema = StructType([
StructField("order_id", StringType(), False),
StructField("customer_id", StringType(), True),
StructField("amount", DecimalType(10, 2), True),
])
data = [
("ORD-1001", "CUST-101", Decimal("250.00")),
("ORD-1002", "CUST-102", Decimal("450.00")),
]
orders_df = spark.createDataFrame(data, schema)
orders_path = "/tmp/delta/orders"
(
orders_df.write
.format("delta")
.mode("overwrite")
.save(orders_path)
)
This creates a Delta table containing three columns.
In a real application, use an appropriate persistent storage location rather than a temporary directory. Also avoid using overwrite mode casually in production, because it can replace existing table data.
Read the table to inspect its contents:
orders = spark.read.format("delta").load(orders_path)
orders.printSchema()
orders.show()
The schema contains order_id, customer_id, and amount.
The next step is to add a column without manually rebuilding the table.
Step 2: Add a Column With Schema Evolution
Suppose the application now sends a payment_method field.
Create a DataFrame with the new column:
from pyspark.sql.types import StructType, StructField, StringType, DecimalType
new_schema = StructType([
StructField("order_id", StringType(), False),
StructField("customer_id", StringType(), True),
StructField("amount", DecimalType(10, 2), True),
StructField("payment_method", StringType(), True),
])
new_data = [
("ORD-1003", "CUST-103", Decimal("320.00"), "UPI"),
("ORD-1004", "CUST-104", Decimal("180.00"), "Card"),
]
new_orders_df = spark.createDataFrame(new_data, new_schema)
To allow this write operation to add the new column, use the mergeSchema option:
(
new_orders_df.write
.format("delta")
.mode("append")
.option("mergeSchema", "true")
.save(orders_path)
)
The mergeSchema option tells Delta Lake to merge supported schema additions from the incoming DataFrame into the target table schema.
After the write, inspect the table:
orders = spark.read.format("delta").load(orders_path)
orders.printSchema()
orders.show()
The table schema now includes payment_method.
Existing records do not suddenly acquire historical payment methods. Because the earlier rows were written before the column existed, their values for this new field will generally be NULL when read.
This is expected behavior. If historical values are required, you must populate them through a separate backfill or transformation based on reliable source data.
Why Use mergeSchema?
Schema evolution is most useful when the change is intentional and backward-compatible, such as adding a nullable column.
The write option keeps the schema change close to the operation that needs it. This is generally easier to reason about than enabling broad automatic schema merging for every write in a shared environment.
The exact support for schema changes depends on the Delta Lake version, the operation, and the type of change. Adding a column is not equivalent to changing an existing column's data type.
Step 3: Understand Existing Records and NULL Values
Adding a new column can affect downstream queries even when the write succeeds.
For example:
SELECT
order_id,
amount,
payment_method
FROM delta.`/tmp/delta/orders`;
Older records may have a NULL value for payment_method.
A downstream application that assumes every order has a payment method may therefore need an update.
You can handle missing values in SQL:
SELECT
order_id,
amount,
COALESCE(payment_method, 'Unknown') AS payment_method
FROM delta.`/tmp/delta/orders`;
However, replacing missing values with a default is a business decision. Unknown should not be used if it would incorrectly imply that the payment method was checked and found to be unknown.
For some datasets, it is better to preserve NULL and let downstream logic distinguish historical records from new records.
Before adding a column, determine how existing data should be interpreted and update the consumers accordingly.
Step 4: Configure Schema Evolution for Streaming Writes
Streaming pipelines often use Structured Streaming to process new data continuously.
The following example shows a streaming DataFrame being written to a Delta table with schema merging enabled:
stream_df = (
spark.readStream
.format("json")
.schema(new_schema)
.load("/data/incoming/orders")
)
query = (
stream_df.writeStream
.format("delta")
.outputMode("append")
.option("mergeSchema", "true")
.option("checkpointLocation", "/checkpoints/orders")
.start(orders_path)
)
This example assumes that the incoming JSON data uses the declared schema and that the source directory is suitable for streaming file ingestion.
The checkpointLocation stores streaming progress and state information needed by Structured Streaming. Use a stable, durable checkpoint location that is unique to the streaming query.
The mergeSchema option can allow supported incoming schema additions to be merged into the Delta table during writes. It does not guarantee that every schema change can be applied without restarting the stream or adjusting its configuration.
A Critical Detail About Streaming Sources
In this example, new_schema already includes payment_method. If the source begins producing additional fields later, the explicitly declared JSON schema does not automatically discover every new field.
For file-based JSON streaming, schema inference and evolution behavior differ from a pipeline that reads from a source with an explicit schema or a managed schema registry.
To handle changing source fields, the ingestion layer must have a deliberate strategy for discovering and validating the incoming schema.
Depending on the source, that may involve updating the declared schema, using a supported schema registry, or adopting a source-specific schema evolution mechanism.
Do not assume that setting mergeSchema on the Delta sink automatically updates the streaming source schema.
Step 5: Manage Schema Changes During Active Streaming
Adding a column to a Delta table while streaming jobs are active requires coordination.
For example, a streaming reader may have been started when the table contained three columns. After a schema change, its behavior depends on the reader's configuration, the operation being performed, and the relevant Spark and Delta Lake versions.
Some streaming queries may need to be stopped and restarted to use a new schema. Other readers may encounter schema compatibility issues when the underlying table changes.
A safe deployment process should therefore account for both the table writer and its consumers.
Recommended Deployment Sequence
Identify the new field and confirm its intended data type.
Update the ingestion process so that it can read and validate the field.
Confirm that the target Delta write supports the intended schema change.
Update downstream transformations and consumers to handle the new field.
Apply the schema change through a controlled write or migration.
Restart or reconfigure affected streaming jobs when required.
Validate the resulting schema, record counts, and representative query results.
The exact order may differ for a particular architecture. For example, a team may update consumers to tolerate an optional field before enabling the producer to send it.
The key is to avoid deploying a new schema without checking the components that depend on the old one.
Step 6: Handle Column Type Changes Carefully
Adding a nullable column is usually less disruptive than changing the data type of an existing column.
Suppose amount was originally stored as a decimal, but a new producer starts sending it as a string:
{
"order_id": "ORD-1005",
"customer_id": "CUST-105",
"amount": "275.50",
"payment_method": "UPI"
}
Enabling schema evolution does not automatically make this change safe. The incoming schema may be incompatible with the target column type, and a write may fail or require an explicit conversion strategy.
A safer approach is to validate the incoming value and convert it to the intended type before writing:
from pyspark.sql.functions import col, try_cast
normalized_df = incoming_df.select(
col("order_id"),
col("customer_id"),
try_cast(col("amount"), "decimal(10,2)").alias("amount"),
col("payment_method"),
)
This example uses try_cast as a SQL expression exposed through PySpark's function interface in supported Spark versions. Verify support in your Spark version before using it. Where it is unavailable, use an appropriate supported conversion and validation approach.
A failed conversion may produce NULL, depending on the expression and runtime behavior. Check for conversion failures before writing so that invalid financial values are not silently accepted.
For an existing column type change, consider a staged migration: introduce a new column, convert and validate data, update consumers, and retire the old column only after compatibility has been established.
Step 7: Validate the Result After the Schema Change
A successful write does not guarantee that the pipeline still meets its business requirements.
Inspect the table schema:
orders = spark.read.format("delta").load(orders_path)
orders.printSchema()
Check the number of records:
orders.select("order_id").count()
Check whether the new field contains missing values:
from pyspark.sql.functions import col
orders.filter(col("payment_method").isNull()).show()
The final query may return historical records that were written before the new column existed. That is not necessarily a data quality failure.
If new records must always include payment_method, validate that requirement against the appropriate subset of recent records or enforce it in the ingestion pipeline.
Also validate downstream models, streaming progress, and dashboard outputs. A schema migration should be considered complete only when the affected parts of the data pipeline behave as expected.
Best Practices for Delta Lake Schema Evolution
Prefer Explicit, Narrow Schema Changes
Use schema evolution for planned changes, such as adding a nullable column. Avoid automatically accepting arbitrary incoming fields without validation.
Keep Schema Changes Backward-Compatible
New optional columns are generally easier to introduce than renaming or changing the type of existing columns.
When possible, deploy consumer changes before producer changes so that downstream systems can tolerate the new field.
Use mergeSchema at the Appropriate Scope
Applying mergeSchema to a specific write makes the intended schema change easier to review.
Avoid enabling automatic schema merging globally unless the behavior is deliberate and appropriate for all affected tables.
Protect Streaming Checkpoints
Use stable checkpoint locations and avoid sharing one checkpoint between independent streaming queries.
When a schema change requires a query restart, follow the relevant Spark and Delta Lake guidance for the source and sink involved. Do not delete checkpoints casually, because doing so may change how the stream resumes processing.
Monitor Streaming Failures
Capture query errors, processing progress, and data-quality metrics. A streaming query can fail for reasons unrelated to schema evolution, so monitoring should include source availability, resource pressure, and processing delays.
Plan for Historical Data
Determine how older records should behave when a new column is introduced. If historical values are needed, define a reliable backfill strategy instead of assuming schema evolution will populate them.
Common Mistakes to Avoid
Assuming mergeSchema Updates Every Component
The option applies to supported Delta write operations. It does not automatically modify every source schema, transformation, streaming reader, or external consumer.
Treating NULL Values as Automatically Incorrect
Existing rows may legitimately contain NULL for a newly added column. Decide whether those values are acceptable based on the business meaning of the field.
Changing Types Without Validation
A type change can introduce conversion failures or incompatible writes. Validate incoming data and migrate existing values deliberately.
Ignoring Downstream Dependencies
A dashboard or transformation may use a fixed schema or assume that every row contains a particular field. Review dependent models and applications before deploying the change.
Overwriting a Table to Add a Column
Replacing the entire table simply to introduce a field can cause data loss or disrupt concurrent readers and writers. Use an appropriate supported schema migration or schema-evolving write instead.
Advantages and Disadvantages
Advantages
Supports planned schema additions without requiring a full table rebuild.
Can reduce manual work when introducing optional fields.
Works with Delta Lake batch and supported streaming write patterns.
Allows data schemas to evolve as application requirements change.
Helps teams manage schema changes through explicit configuration.
Disadvantages
Schema evolution does not guarantee compatibility across all readers and writers.
Streaming sources may require separate schema management.
Existing records may contain NULL values for new columns.
Incompatible type changes require additional migration work.
Uncontrolled schema merging can introduce unexpected fields into shared tables.
Summary
Delta Lake schema evolution helps teams introduce supported schema changes, such as new columns, without rebuilding an entire table. For a planned column addition, a write using mergeSchema can update the target schema while preserving existing records.
Streaming pipelines need additional care. The source schema, Delta sink, checkpoints, downstream transformations, and active readers must all be considered. Schema evolution on the sink does not automatically update every component in the pipeline.
The safest approach is to make schema changes explicit, validate incoming data, update dependent consumers, and test the pipeline after deployment. This allows teams to evolve their data models while reducing the risk of broken streaming jobs and unreliable dashboards.
Join the conversation! Your thoughts help the community grow.