Skip to content

0023: Schema changes on the source, and switching to a newer capture instance

Status: accepted
Date: 2026-09-30T18:40:00-03:00
Amended: 2026-09-30T20:09:44-03:00, run against SQL Server 2022 (tests/integration); a type bronze cannot take names delta.enableTypeWidening
Amended: 2026-09-30T21:25:30-03:00, drop the older instance one batch after the switch event; schema checked at an instance's first read; uncaptured declared columns fail; detail is the DDL statement
Amended: 2026-10-01T15:55:00-03:00, type widening on silver is a manual ALTER COLUMN ... TYPE: apply_changes merges without schema evolution
Amended: 2026-10-01T17:25:27-03:00, lab check t9 runs the switch under a continuous writer on SQL Server 2022 and 2017; the production SQL Server 2016 SP3 change tables have __$command_id
Amended: 2026-10-01T18:15:53-03:00, the switch tests run with the arrow-odbc backend too (ADR 0003 Amendment 2)
Amended: 2026-10-07T01:41:18-03:00, the reader's other warnings (columns not read, unknown options, a fixed UTC offset) reach the batch's facts row as detail {"warnings": [...]} (Amendment 6)
Amended: 2026-10-13T18:45:19-03:00, snapshot_on_switch in chunked mode: the switch batch opens a chunked snapshot at the newer instance's start LSN, which backfill() reads next to the stream (Amendment 7)
Amended: 2026-10-15T09:50:28-03:00, a capture_instance_switched row's detail is JSON: old, new, start_lsn, not_captured and the former line as text (Amendment 8)

Context

A capture instance captures a fixed column list, chosen when it is enabled. To capture a column added to the table, SQL Server's way is a second capture instance of the same table (at most two), then disabling the first. Until now the library read one capture instance with the schema of load() and knew nothing about DDL. Measured on SQL Server 2022 CU27 and 2017 CU31 in Docker, library at 03a0f3a, with a least-privilege login (SELECT on the source table and on cdc.<ci>_CT only):

  • ADD COLUMN: the capture instance does not change (captured columns and change table stay); cdc.ddl_history gets a row with required_column_update = 0; an UPDATE that touches only the new column writes no change row. The stream kept going and the new column's changes were lost until a new instance existed.
  • DROP COLUMN of a captured column: sp_cdc_get_captured_columns still lists it and the change table keeps it; rows captured after the drop have NULL there (before-images too); a commit made just before the DROP but captured after it kept its value; a ddl_history row (0). The stream continued and bronze got NULLs, but snapshot(), bootstrap=True and on_data_loss="resnapshot" failed with Invalid column name: the snapshot selected the captured columns from the source table.
  • ALTER COLUMN type (decimal(9,2) to (18,4), varchar(10) to (50), int to bigint): sp_cdc_get_captured_columns reports the new type, the change-table column is altered and its rows converted, and the ddl_history row has required_column_update = 1. A running query kept its load-time schema: it wrote the values that fit and failed on the first that did not (pyarrow ArrowInvalid: Rescaling Decimal value would cause data loss). On restart the schema was re-inferred and the Delta append failed with DELTA_FAILED_TO_MERGE_FIELDS; with delta.enableTypeWidening = true on bronze plus schema merging, Delta widened the column and wrote.
  • Other DDL: a NOT NULL change writes a ddl_history row (0). Renaming a captured column is refused (REPLICATED). Renaming an uncaptured column or the table is allowed and not recorded; after a table rename sp_cdc_help_change_data_capture shows the new name under the old instance. TRUNCATE and altering the key column are refused. Adding back a dropped column's name with another type makes a new column_id: captured and source columns must be compared by column_id, not by name.
  • Visibility: capture writes ddl_history asynchronously, but over 2,746 samples and 8 DDLs there was no case where max_lsn >= ddl_lsn and the row was not yet visible through sp_cdc_get_ddl_history to the least-privilege login: a batch that ends at or below max_lsn sees its DDLs. ddl_time comes in whole minutes.
  • Permissions: with SELECT on the source table the login can call sys.sp_cdc_get_ddl_history, sp_cdc_get_captured_columns, sp_cdc_help_change_data_capture (which lists both instances of the table), fn_cdc_get_min_lsn and fn_cdc_get_column_ordinal; it cannot read cdc.ddl_history, cdc.change_tables, cdc.captured_columns or cdc.index_columns. With only the change-table grant the procedures say "Object does not exist or access is denied", the help procedure returns nothing and min_lsn is 0x00. A new instance's change table needs its own GRANT SELECT.
  • A second capture instance: its start_lsn S is the commit LSN of the enable (in cdc.lsn_time_mapping). Right after the enable max_lsn is below S and fn_cdc_get_min_lsn(v2) is 0x00 until capture processes the enable (2.9 s under load; up to ~5 minutes on a quiet database, ADR 0016). Under 50 inserts/s, of 685 commits, 259 were only in v1 (all with LSN < S), 426 in both (all >= S, with identical (start_lsn, seqval)), none only in v2 and none in neither. So reading v1 below S and v2 from S reads every commit once. The enable waits while a transaction that already wrote the table is open (3.4 s; that commit is only in v1, below S); a transaction that began earlier but writes only after the enable does not block it and is complete in both. __$command_id differs between the instances for the same change (v1 1, 3, 3, 5; v2 2, 4, 4, 6), each keeping the order: (start_lsn, seqval, operation) identifies a change across instances, command_id does not. A third instance is refused. sp_cdc_disable_table on v1 takes effect at once: its change table is gone, min_lsn is 0x00, its ddl_history rows are deleted and readers get Invalid object name. DDL made before v2 existed is not in v2's history. Documented only: cleanup moves start_lsn of every instance up to its new low watermark, so two instances can share one; Debezium breaks that tie by create_date.
  • The library before this change: Spark accepted a changed source schema on restart with the same checkpoint (foreachBatch). Switching captureInstance to v2 before the stream had read v1 up to S raised DataLossError (right), but blamed CDC cleanup; after catching up on v1 the switch worked, since offsets are database-wide LSNs. v2's new column failed the bronze append with DELTA_METADATA_MISMATCH (no mergeSchema), and a column v2 lacked was filled with NULL by Delta. A rerun with bootstrap=True after the switch wrote a second full snapshot: the lookup filtered _capture_instance = ci, as did the ADR 0018 recovery. Once v1 was dropped, a stream still configured for it failed at load() with "not found", not naming v2.
  • Others: Debezium's SQL Server connector leaves the new instance to the DBA, cuts the old one at the new one's start_lsn (the same rule), emits a notification, and drops nothing; it reads cdc.change_tables, which invariant 11 rules out here. Estuary switches to the newest instance with start_lsn <= its position, and can manage instances itself with db_owner. Lakeflow Connect uses a DDL trigger (db_owner). Fivetran reads one instance. Fabric mirroring fails on DDL and reseeds. AWS DMS reads the log instead.

Decision

The offset contract does not change (invariant 1): every LSN is database-wide, so which capture instance holds a range follows from the instances' start_lsn, computed again each time a batch is planned, replays included.

D1. Detect DDL on the driver, every batch, and react by kind

  • When planning a batch, the reader asks sys.sp_cdc_get_ddl_history(<ci>) of each instance the batch reads for entries with ddl_lsn in (start, end] (CdcClient.ddl_history), and compares the captured types (sp_cdc_get_captured_columns) with the query's schema by name (with columns, with the captured types at the run's first planning, since the declared ones are the user's own conversions).
  • A type change of a captured column that the query's type can no longer hold fails the planning of the batch that contains it, before any row is read, with SchemaChangedError (a RuntimeError, exported): "restart the query to re-infer the schema (and enable delta.enableTypeWidening on bronze for a widening)". required_column_update = 1 alone does not fail it: the batch replayed after the restart holds the same DDL, and would fail forever; a change that still fits (varchar(10) to (50), both STRING) is a schema_change event.
  • ADD and DROP of columns and any other DDL: the stream continues, logs a warning, and leaves a schema_change event for the facts (below).
  • Option schemaChangePolicy: classify (the default, as above) or fail (any DDL in the batch fails it; the replayed batch fails again, so the message says to restart once with classify to go past it).
  • Considered:
  • Diffing sp_cdc_get_captured_columns each batch alone: it sees type changes only; an ADD leaves the captured list as it was and a DROP stays listed.
  • Reading cdc.ddl_history or a DDL trigger on the source: the first needs a grant beyond invariant 11, the second db_owner and writes to the source (Lakeflow Connect).
  • Failing on every DDL (Fabric mirroring): a NOT NULL change, or a column nobody captures, would stop ingestion for nothing. Ignoring DDL, as before: the type change failed mid-batch in an executor with an Arrow error, after some rows were written.
  • sp_cdc_get_ddl_history is documented, readable by the least-privilege login, and was never behind max_lsn in the measurements.

D2. Follow a newer capture instance of the same table

  • The reader lists the table's instances with one sys.sp_cdc_help_change_data_capture call without arguments, the listing source_table already reads, which applies the same permission check per table (CdcClient.capture_instances, oldest first); newer means a later create_date (a tie on start_lsn after cleanup is broken by it).
  • partitions() cuts [from, to] at S, the newer instance's start_lsn: the older instance for [from, S - 1] (clamped to the range), the newer for [S, to], never an empty range (invariant 3). Each LsnRange names its capture instance, so executors stay stateless (invariant 5), and the retention guard runs per instance: a range of the older instance checks the older's min_lsn (invariant 4).
  • Schema at load(): the union by column name of the instances' captured columns, in capture order, older first. Two types for one name resolve to the newer's when it holds the older's values; otherwise load() fails with SchemaChangedError. A range selects typed NULL for a column its instance lacks.
  • Ordering across instances is (_start_lsn, _seqval, _operation): _command_id numbers each instance's rows its own way, so it breaks ties only within one _start_lsn, which the cut at S keeps within one instance. The existing key (_start_lsn, _command_id, _seqval, _operation) stays right for the same reason; the bronze column comment says so.
  • A newer instance that appears while a query runs: the query switches in place when its schema holds every column the newer instance captures, with a type that fits, and otherwise fails with SchemaChangedError at the boundary, before reading past S, so that the next load() infers the new columns. With columns the declared list decides: the switch is in place, with a warning naming captured columns it leaves out.
  • A configured instance that was dropped: load() and planning follow the newest instance of its table (found through its default name <schema>_<table>, or through the newest name the run has seen); otherwise the not-found error names the table's instances.
  • DataLossError names the dropped older instance beside cleanup when a gap is in the table's oldest remaining instance and the run has seen an instance the table no longer has (amendment: where the gap lies does not tell the two apart); a PermissionError on the newer change table names its own grant.
  • The reader leaves a capture_instance_switched event (detail old -> new, plus the columns the query reads that the newer instance no longer captures, NULL from S on) when it plans the first batch that reads the newer instance. The older one can be dropped after the stream's next batch, not before (amendment): the sink commits the event row inside foreachBatch, before Spark commits the batch (and before the snapshot_on_switch snapshot), and a replay of that batch reads the older instance below S again. Moving the event a batch later would need state across runs to emit it once, and snapshot_on_switch needs the batch that crosses S. A stream that starts at or past S (a bootstrap after the enable, startingLsn=latest) never reads the older instance and leaves no event.
  • A query's schema is checked against an instance's columns the first time the run reads it (amendment), not only at S: a stream that skips ahead into the newer instance (failOnDataLoss=false after the older was dropped) fails the same way. A source column no instance of the table captures (a typo in columns, a column outside @captured_column_list) fails the run's first planning and the snapshot with a ValueError instead of reading NULL in every change row.
  • Considered:
  • The user changes captureInstance by hand (the state before): it needs the stream to have read exactly up to S first, or it fails, and every consumer does it at its own moment.
  • A new checkpoint or stream per instance: a second checkpoint (invariant 8 needs a new app_id) and a gap or an overlap to reconcile at S.
  • Estuary's rule, the newest instance with start_lsn <= the position, switches only at a batch boundary; cutting inside the batch lets one batch span the switch.

D3. The DBA creates and drops instances; the library never does

sql/switch_capture_instance.sql documents the procedure: enable the new instance with the new column list, GRANT SELECT ON cdc.<new>_CT TO <reader>, wait for every stream's capture_instance_switched event and one batch more, then disable the old one. Considered: letting the library manage instances (Estuary): it needs db_owner and writes to the source, against invariant 11.

D4. Bronze takes new columns; type widening is the owner's choice

Every bronze append, micro-batches and snapshots alike, uses mergeSchema: a column the newer instance captures is added (older rows read NULL), and a column the batch lacks is NULL. A type change fails the append unless the bronze table has delta.enableTypeWidening = true and the change is a widening Delta supports; the library documents the property and never sets it. The failed append raises SchemaChangedError naming the property and the ALTER TABLE that sets it (amendment): Delta's own error, [DELTA_FAILED_TO_MERGE_FIELDS] Failed to merge fields 'amount' and 'amount', says neither. Considered: overwriteSchema (a rewrite of the table) and casting new types back to the old ones (silent truncation).

D5. Rows unchanged since a switch keep NULL for a newly captured column

The latest image of a row that did not change after S has NULL for a column only the newer instance captures. to_delta(..., snapshot_on_switch=False): with True, after the batch that first reads the newer instance (the capture_instance_switched event), the sink appends a snapshot (ADR 0016's machinery, stamped with max_lsn recorded before its read), so the latest image carries the new column's values. Considered: always snapshotting at a switch (a full read, too slow for large tables, see tables too big to snapshot) and never (a column that stays NULL until each row changes).

Needed whichever option

  • The snapshot reads typed NULL for a captured column the source table no longer has, matched by column_id (CdcClient.present_columns), instead of failing.
  • Existing snapshots are looked up by the set of capture instances of the same source table, case-insensitive (pipeline._instances): snapshot(), bootstrap=True and the ADR 0018 recovery. The recovery's retention test uses the min_lsn of the instance the source reads the next changes from: the newest one starting at or before them, else the oldest.
  • Silver (ADR 0019) reads the rows of that set: with options, the table's instances come from SQL Server. A row in the range to apply of any other instance fails the call, naming it, instead of being skipped. A column bronze gained is added to silver.
  • Facts events. The reader, which cannot write Delta, leaves one JSON file per event in the metrics directory (metricsPath), event-<kind>-<lsn>.json (ADR 0018's 'data_skipped': event-data_skipped-<ci>-<from>.json) with event, capture_instance, lsn, commit_ts and detail. The sink writes each as a facts row of the batch, in the same Delta commit as the batch's own row (txnAppId <app_id>#facts, txnVersion the batch id), so a replay writes both or neither, and removes the files only after that commit; a file with the same kind and LSN is one event. The row has event set to the kind, the batch's batch_id, rows = 0, min_lsn = max_lsn = end_lsn the event's LSN, end_commit_ts its commit time, lost_* NULL, and the new column detail (facts migration 6), which also rewrites the comments of batch_id, rows, event, end_lsn and end_commit_ts. Without metricsPath, events are warning logs only.
  • Considered: a separate txnAppId per event kind (a second event of one kind in a later batch needs a new version anyway) and writing from the driver (the reader has no Spark session, and the rows would land before the batch's data).
  • Bronze migration 1 gives existing tables the new comments of _capture_instance (the instance each row actually came from) and _command_id (numbered per instance).

Consequences

  • A facts consumer that took event IS NOT NULL for "a snapshot" (as ADR 0018 and the README suggested for the rebuild point) must now filter event IN ('bootstrap', 'resnapshot'): schema_change and capture_instance_switched rows carry rows = 0 and a batch_id. Statistics over micro-batches still filter event IS NULL. Silver and the tests' helpers do.
  • A type change needs a restart; a widening also needs delta.enableTypeWidening on bronze. Silver needs more (amendment 3): apply_changes adds new columns but does not change types, and its MERGE runs without schema evolution, so the property alone leaves silver's column at the old type; its owner widens it with ALTER TABLE ... ALTER COLUMN ... TYPE. A narrowing or an incompatible change needs a new bronze table or a rewrite.
  • The newer instance's change table needs its own grant before the stream reaches S; the first read without it raises a PermissionError naming it.
  • The older instance can be dropped once every stream reading the table has a batch after its capture_instance_switched event (a facts row of the same app_id with a larger batch_id); a stream already at or past S needs none. The event reaches the facts only through metricsPath, which to_delta defaults only for a local or Volume checkpoint. Dropped earlier, the changes below S that only it held are lost: DataLossError names cleanup and the dropped instance as the two possible causes (where the gap lies does not tell them apart), and on_data_loss="resnapshot" recovers with a snapshot.
  • With columns, the D1 type check compares with the captured types at the run's first planning, so it catches a type change made while the query runs. One made while it is stopped is not caught at planning: the read fails casting to the declared type, and columns needs the new type.
  • Planning asks one sp_cdc_help_change_data_capture and, per instance read, one sp_cdc_get_ddl_history and one sp_cdc_get_captured_columns more per batch.
  • After the old instance is dropped and captureInstance renamed to the new one, SQL Server no longer lists the old name: rows stamped with it, a bootstrap snapshot included, match nothing. A bootstrap=True rerun then takes one more snapshot (harmless, only slower), and a silver table built from scratch fails loudly on the old rows (keep the old name configured, as the stream follows it). ponytail: record the table's instance names in the facts events and match on those if this shows up.
  • SQL Server 2016 SP3: the sp_cdc_* procedures used here exist with no documented per-version differences. __$command_id came with KB3030352 (2016 RTM CU5, SP1 CU2), and the production change tables have it (amendment 4: read through the library on 2026-09-30). CURRENT_TIMEZONE_ID is absent (the ADR 0008 fallback applies). 2016 left extended support on 2026-07-15.
  • Lab check t9 (amendment 4) runs the switch end to end on SQL Server 2022 CU27 and 2017 CU31: a continuous writer, sql/switch_capture_instance.sql as written, a least-privilege login, the running query stopped at S and restarted, the old instance dropped after the event and one batch more. Bronze held every change once, its latest image equalled the table, the stream kept running.
  • Unverified: the switch on SQL Server 2016; Databricks; a tie on start_lsn after cleanup (documented, not reproduced); ddl_history visibility is measured (8 DDLs), not documented.
  • Tests: tests/test_delta_sink.py (event files folded once across a replay, mergeSchema, bronze migration 1, a switch adding a column with its events and no second bootstrap snapshot, following the new instance after the old is dropped, snapshot_on_switch, a snapshot after a DROP COLUMN, a re-snapshot after the old instance was dropped too early, the type widening message) and tests/test_silver.py (silver across a switch, with and without options). tests/integration, against SQL Server 2022 with a least-privilege login (amendment): a new instance enabled under a continuous writer (every commit once, below S from the old instance), the old one disabled too early (DataLossError, then on_data_loss="resnapshot"), a type change stopping a running query before its batch is written and widened on restart, bootstrap and re-snapshot after a DROP COLUMN, an ADD COLUMN reaching bronze through a new instance with snapshot_on_switch, the missing grant on the new change table, and a transaction open during the enable (only in the old instance, below S). They also pinned that an update of only a column an instance does not capture writes no change row there; the fake now does the same.

Amendment 6: the reader's other warnings reach the facts

The reader logs on the driver, to the stderr of the Python worker Spark runs the source in, which jobs rarely keep. Changes on the source were already event rows; the warnings about what the query reads were in that log only: captured columns that columns leaves out at a newer instance (D2), computed columns left out of the inferred schema or listed in columns (ADR 0007), unknown options, and the UTC offset it converts commit times with on a server older than SQL Server 2022 (ADR 0008).

  • The reader keeps each until it plans a batch with a range to read. The batch's last range carries them to its task, which writes them in its metrics file, and the sink puts them in the detail of the batch's own row as JSON {"warnings": [...]}, only when there are any (facts migration 12, a comment). They ride the batch's facts commit, so a replay writes them once, and a retried task rewrites its file with them. Without metricsPath they stay in the log.
  • A restarted run's first batch can replay one whose facts commit was written before the crash, which Delta then skips with the new run's warnings in it. The sink knows it from the facts (the batch id is the largest the app_id wrote, as its resume check reads) and puts them in the next batch's row instead.
  • A warning is recorded as often as it is logged: once per run, and the offset again when it changes. One logged elsewhere (the computed columns by load(), the offset by the client) is recorded by the reader, not logged again.
  • Retries on a new connection (ADR 0029) stay in the log: the batch went through.
  • Considered: an event row per warning, a new event value for every consumer's filters to know where the batch's row has detail free; and an event file written while planning, which the sink folds into whatever batch comes next, a dead attempt's included, where a dead attempt's partition files are removed before the read.
  • Tests: tests/test_reader_units.py (each warning on the batch's last range, once, and in its task's metrics file) and tests/test_delta_sink.py (a misspelt option and a column a newer instance captures reach the batch's row once across a replay; a restart's warnings reach the next batch's row when its first batch replays one already in the facts).

Amendment 7: the snapshot after a switch, chunked

D5's snapshot reads the whole table inside the batch that read the switch, and the stream waits for it, so to_delta refused snapshot_on_switch=True with snapshot="chunked": the tables too big to snapshot, the ones that take chunked snapshots, kept NULL in the new column until each row changed. Now, with snapshot="chunked", that batch only opens a chunked snapshot (ADR 0028) and backfill() reads it next to the stream, as a chunked bootstrap; with "full" nothing changes.

  • Its S is the newer instance's start LSN, the switch event's min_lsn, not max_lsn after the batch as the full snapshot takes. The batch read past it, and its changes are in bronze before the snapshot opens, so the stream has every change after S, as a generation has after its chunked snapshot's S. Silver's rebuild (the rows of S and the changes after S) and reconcile's position (a chunked snapshot's S, which the stream has read up to: ADR 0028 Amendment 2) hold as they are. Every chunk is stamped with max_lsn before its wave, after the batch's end and so after S; S is recorded before any row is read, but is not max_lsn (invariant 12). S lies after the batch's start, which is at or after the S of any chunked snapshot of the generation: it is the newest, and supersedes one still open, as a newer open does (backfill(), silver and reconcile take the newest); that one's chunks are read again, now with the new column.
  • Its 'snapshot_open' row has kind 'bootstrap' (no change was lost: a 'resnapshot' row is what monitoring alerts on), the running generation, and a new key switch_batch_id, the batch_id of the switch's 'capture_instance_switched' row. The generation's own Delta keys may be taken by its bootstrap or re-snapshot, so the open is keyed by S, <app_id>#snapopen.<S>, 0, as its plans table row, and backfill() writes the completion, a 'bootstrap' row, as <app_id>#snapdone.<S>, 0 when the open has switch_batch_id. A replay of the switch batch opens it at the same S: Delta skips both rows, and it is read back.
  • One mode per run: a chunked run with snapshot_on_switch checks the lock at its start (ADR 0028), and the open snapshot holds the mode for the runs and backfill() calls after it.
  • State: facts migration 14 and plans migration 1, comments only (event and detail), and the added key (ADR 0021 amendment 10).
  • Considered: S at max_lsn after the batch, as the full snapshot: above the stream's position, so reconcile would need the facts to tell a switch's S from a generation's, and a commit in between would be in neither bronze nor its read of the change table. A new kind or event ('switch'): every consumer that filters event IN ('bootstrap', 'resnapshot'), and an older release's silver, would miss its completion. Keeping the open chunked snapshot and reading only the rest: its chunks read before the switch lack the new column.
  • Tests: tests/test_silver.py (a chunked snapshot opened after a switch, read by backfill() in two calls with a delete and an update between them: silver equal to the source with the new column; its completion next to the bootstrap's in one generation; the open read back when the switch batch replays; a full run refused while it is open; a rerun opens nothing), tests/test_migrations.py (plans migration 1) and tests/integration (against SQL Server 2022, the sql/switch_capture_instance.sql steps with a chunked bootstrap: the snapshot opened at the new instance's start LSN, silver equal to the table with the new column after backfill(), and again after the old instance is disabled).

Amendment 8: the switch's detail as JSON

The switch's detail was a line for people (old -> new, then the columns no longer captured), so a consumer that wanted the instance names, to wait for a switch in step 4 of sql/switch_capture_instance.sql or to alert on uncaptured columns, parsed text that may be reworded in any release (ADR 0021 amendment 11). From 1.0.0rc1 the reader writes it as JSON, through events.switch_detail: {"old", "new", "start_lsn", "not_captured", "text"}, text the former line, so every piece it held is still there, and start_lsn the row's min_lsn. The driver's warning still logs the line.

  • State: rows written before keep the text (ADR 0021 amendment 12); events.switch_of reads either as the same payload, and the queries in the schema changes guide and the SQL script read coalesce(get_json_object(detail, '$.text'), detail). Facts migration 15 rewrites the detail comment only.
  • Tests: tests/test_source_fake.py (the event file's JSON, with and without a column the newer instance does not capture), tests/test_delta_sink.py (the facts row through to_delta), tests/test_payloads.py (switch_of on both forms) and tests/integration (against SQL Server 2022); lab check t9 reads it with switch_of.