Skip to content

Output schema

The columns of the stream, spark.readStream.format("mssql_cdc"), and of the snapshot, spark.read.format("mssql_cdc_snapshot"). Both have the same schema: six metadata columns, then the captured columns. What the sink adds on top, and the other tables, are in Tables.

from mssql_cdc import register

register(spark)  # stream() does this for you
changes = spark.readStream.format("mssql_cdc").options(**options).load()
current = spark.read.format("mssql_cdc_snapshot").options(**options).load()
changes.printSchema()

Metadata columns

Column Type Change rows Snapshot rows
_capture_instance STRING the capture instance the row was read from: from a newer instance's start LSN on, that one (Schema changes) the instance the snapshot was taken for
_start_lsn STRING __$start_lsn, the commit LSN of the source transaction, as 0x + 20 uppercase hex. All rows of a transaction share it; string order is commit order the snapshot's LSN, recorded before the table was read
_seqval STRING __$seqval, the change's position in the log, same format NULL
_operation INT 1 delete, 2 insert, 3 update (the row before, with the NULLs below), 4 update (the row after) 0
_command_id INT __$command_id, the order of the statement within its transaction, numbered per capture instance. Absent with includeCommandId=false NULL
_commit_ts TIMESTAMP_NTZ the commit time from cdc.lsn_time_mapping, converted to UTC (sourceTimeZone) the commit time of _start_lsn

An update is two rows, operation 3 and operation 4.

A source column whose name equals one of these, or one the sink adds (_batch_id, _snapshot, _chunk), ignoring case, raises ValueError when the stream or snapshot loads: leave it out with columns.

Ordering changes

Order rows with (_start_lsn, _command_id, _seqval, _operation), for example in bronze:

history = spark.read.table("bronze.orders").orderBy(
    "_start_lsn", "_command_id", "_seqval", "_operation"
)

After a switch to a newer capture instance, the two instances number the same change differently in _command_id, but all the rows of one commit come from one instance, so the order holds. Across instances, (_start_lsn, _seqval, _operation) identifies a change. The rows of a whole snapshot share one _start_lsn, below every change read after them, so a MERGE that keeps the latest image per key absorbs the overlap between a snapshot and the stream (Silver does that for you). A chunked snapshot's rows carry their wave's stamp instead, at or after the snapshot's LSN, and can hold a commit newer than it: rebuild from the snapshot's rows and every change after its LSN, and do not read a chunk row as the table's state at its stamp (ADR 0028).

Captured columns

Without the columns option, the driver infers them at load() from sys.sp_cdc_get_captured_columns, in capture order. When the table has two capture instances it takes the union by name, older instance first; a column both capture with different types takes the newer type when it holds the older's values, otherwise load() fails with SchemaChangedError. A range of an instance that lacks a column reads it as NULL.

A computed column is left out, and load() logs a warning naming it: CDC stores NULL for it in every change row (below), so its value never reaches a change. Compute it downstream from the columns it is computed from. The client tells it by sys.columns.is_computed, which a login that can SELECT the table sees.

Default types, from mssql_cdc.client:

SQL Server Spark
bit BOOLEAN
tinyint, smallint SMALLINT (0 to 255 does not fit Spark's signed TINYINT)
int INT
bigint BIGINT
real FLOAT
float DOUBLE
decimal(p,s), numeric(p,s) DECIMAL(p,s)
money DECIMAL(19,4)
smallmoney DECIMAL(10,4)
date DATE
datetime, datetime2, smalldatetime TIMESTAMP_NTZ
datetimeoffset TIMESTAMP
time STRING
char, varchar, nchar, nvarchar, text, ntext, xml, uniqueidentifier STRING
binary, varbinary, image, timestamp (rowversion) BINARY

Alias types, such as sysname, map through their base type. Any other type (sql_variant, geography, geometry, hierarchyid) fails at load() with no default Spark type for SQL Server type ...: pass columns, leaving that column out. Only _commit_ts is converted to UTC; captured datetime and datetime2 values arrive as stored. Every batch is cast to this schema before Spark sees it.

With columns, the declared list and types are the schema, for example order_id INT, status STRING, amount DECIMAL(18,2); the read casts to them. A computed column listed there stays in the schema, NULL in every row, snapshot rows too, and the first planning logs a warning naming it.

NULLs in change rows

SQL Server CDC does not store every value in the change table, so some change rows hold NULL where the source row had a value (cdc._CT):

Captured column NULL in
a computed column every change row
text, ntext, image operations 1 (delete) and 3 (the row before an update)
varchar(max), nvarchar(max), varbinary(max) operation 3, unless the update changed the column

The snapshot reads the source table, so its rows hold the large values these change rows lack. A computed column is not read: the inferred schema leaves it out, and one listed in columns reads NULL in snapshot rows too, so every row agrees. reconcile() does not compare computed columns.

Snapshot rows

The snapshot reads the source table itself, under READ COMMITTED (never NOLOCK), in the same schema. A captured column the source table no longer has (dropped, matched by column_id) reads NULL, as its change rows do since the drop; so does a computed column listed in columns. Written by snapshot() or to_delta, snapshot rows also get _batch_id NULL in bronze, and _snapshot, the LSN of the snapshot they belong to (ADR 0016).

With snapshotChunks the snapshot reads only those chunks and has one more column, _chunk INT, the chunk each row was read in. backfill() writes them to bronze with _snapshot set to the chunked snapshot's LSN S, while each wave's _start_lsn is its own stamp, at or after S (ADR 0028).