Autonomous in the loop. Accountable at the gates. An agentic offering by Plainsight

Migrate T-SQL to Microsoft Fabric.

Stored procedures, views and functions onto the Fabric Warehouse: procedural batches turned into a tested model DAG, not a risky lift-and-shift.

Microsoft Fabric logo

T-SQL onto the Microsoft Fabric Warehouse: continuity, with real edges

Of every legacy Microsoft pairing, T-SQL to Microsoft Fabric has the most continuity. The Fabric Warehouse is primarily developed with T-SQL, shares a large surface area with the SQL Database Engine, and gives you full multi-table ACID transactions, views, functions and stored procedures over Delta tables in OneLake. So the instinct (“it’s T-SQL on both sides, just copy it across”) is half right, and the half that’s wrong is where migrations quietly fail.

The Warehouse is a subset of the T-SQL you know, and the gaps are exactly the constructs procedural estates lean on. There are no triggers, no materialized views, no synonyms, no recursive queries, no SELECT ... FOR XML, no SET ROWCOUNT, no SET TRANSACTION ISOLATION LEVEL, and no manually created multi-column statistics. ALTER TABLE is limited to a subset: add nullable columns, drop columns, add or drop keys, all NOT ENFORCED. CLR is not available, so any procedure with EXTERNAL NAME has no in-engine home. A lift-and-shift that ignores this list compiles right up until the object that depends on one of these features, and then it’s a runtime surprise instead of a design decision.

That is why the fleet documents the logic, not just the labels. The T-SQL Documenter dissects the procedural layer (stored procedures, views, functions) separating business logic from plumbing, tracing dependency chains across objects and databases, and flagging the dialect quirks and dynamic SQL that need careful handling on a new engine. The Architect then decides what each object becomes on Fabric and writes it into the Target Design Spec your experts sign off at the design gate. Nothing in the surface-area gap reaches a build without a human having seen it first. Autonomous in the loop, accountable at the gates.

What a naive lift-and-shift breaks

Four things go wrong most often, and all four are visible in the iteration log for this pair.

Procedural flow assumed, not modelled. A 400-line stored procedure encodes execution order inside IF/WHILE/BEGIN...END. Copy it verbatim and you’ve copied the order too, but none of the testability. The dbt flavor takes the more deliberate path: it decomposes the batch into ordered, idempotent models wired together with ref() and source(). Those calls are the edges of a DAG dbt builds in topological order, so an incremental fact model that ref()s its staging models always runs after them, with no manual sequencing. The procedure stops being a black box and becomes a graph you can test node by node.

Constraints presumed enforced. On a SQL Server box a FOREIGN KEY rejects an orphan row. On the Warehouse the same key is metadata only - NOT ENFORCED - so it documents intent without policing inserts. The fix is to move that guarantee into tests: unique, not_null, accepted_values and relationships on the dbt side (built from tsql-utils, since dbt-utils isn’t supported by the adapter), or equivalent assertions in your framework. Integrity becomes something checked every run, not something the engine silently stopped doing.

Dynamic SQL transliterated. Dynamic SQL is the most common place for undocumented behaviour - the executed statement isn’t present statically, so it has to be traced through the variable that builds it. The fleet splits it by when the SQL is decided. Compile-time variation (column lists, environment suffixes) becomes a dbt Jinja macro, so the generated statement is reviewable in the build artifact. Runtime variation stays as dynamic T-SQL inside a Warehouse procedure. Both are reconstructed and documented; neither is left as an opaque EXEC(@sql).

Triggers and recursion carried over. A DML trigger that writes an audit row has no Warehouse equivalent, and a recursive CTE that walks a hierarchy won’t run. The Operator catches both in the build-test-run loop: the run fails, the failure routes back to the builders, the trigger becomes an explicit step and the recursion becomes bounded staged models - and the loop re-runs until the suite is green.

MERGE, SCD2, and the two flavors

Upserts land cleanly: MERGE is generally available in the Warehouse, so a SCD Type 1 upsert ports almost as-is on the your-framework path, wrapped in BEGIN TRAN / COMMIT TRAN for atomicity. On the dbt path the same intent becomes an incremental model whose default merge strategy generates that T-SQL MERGE for you, keyed on your unique_key. History is where the flavors diverge most usefully: hand-rolled SCD Type 2 history tables become dbt snapshots, which close the old row and insert the new one with dbt_valid_from / dbt_valid_to / dbt_scd_id maintained for you, or, in your framework, a MERGE-based effective-dating procedure that does the same thing in T-SQL you can read.

The Fabric Test Agent then writes the suite (schema parity, row-count reconciliation, SCD effective dating, null and key integrity) and the Reconciler runs legacy and rebuilt side by side until the delta is zero or explained in writing. The deliverable is the same whichever flavor you pick: the same source logic, the same tests, proven against the procedure it replaced.

If your estate also has a Spark-shaped future, the same source logic can target the other engine, see Migrate T-SQL to Databricks for how the procedural layer maps onto Delta and Unity Catalog instead.

How the fleet runs it

T-SQL to Microsoft Fabric, stage by stage.

For T-SQL → Microsoft Fabric, the same five stages run with the agents that handle this pair named and linked below. Inside the loop they iterate on their own; your experts hold the three gates.

Build → Test → Operate iterates until green on Fabric

Orchestrated end-to-end by The Conductor , The Librarian and The Chronicler across every T-SQL → Microsoft Fabric asset.

dbt or your framework

Built your way, on Fabric.

Take T-SQL to Microsoft Fabric as an open-source dbt project (free to run), or built into the framework your team has trusted for years: same migration, your standards either way.

What gets generated

dbt
  • A dbt project targeting the Microsoft Fabric Warehouse via the dbt-fabric adapter (type: fabric, ODBC Driver 18, the Warehouse SQL analytics endpoint as host)
  • Staging and mart models that decompose procedural stored procedures into ordered, idempotent SELECTs, wired by ref() and source() into a DAG
  • Incremental models using the default merge strategy (T-SQL MERGE) keyed on your unique_key, with append and delete+insert where the pattern fits
  • snapshots for SCD Type 2 history tables (dbt_valid_from / dbt_valid_to / dbt_scd_id), replacing hand-rolled T-SQL history logic
  • Generic and singular tests (unique, not_null, accepted_values, relationships) plus dbt docs lineage: using tsql-utils, not dbt-utils
  • Jinja macros that template what was once runtime dynamic SQL at compile time

Pattern mapping

Each T-SQL pattern, mapped to a deliberate Microsoft Fabric target.

Source pattern
Microsoft Fabric · dbt
Microsoft Fabric · your framework
Stored procedure (multi-step, set-based)
Decomposed into ordered models connected by ref(); side effects via pre/post hooks or operations
Warehouse CREATE [OR ALTER] PROCEDURE, invoked by the pipeline Stored procedure activity
View / view-over-view
A view-materialized model (or table where it pays off); upstream views become upstream models
Recreated as a Warehouse view; nested views flattened or kept as a view chain
Scalar / table-valued function
Re-expressed as set-based SQL in a model, or a Jinja macro for reuse
Inlineable scalar UDF or view; non-inlineable logic folded into the calling query
MERGE upsert in a proc
Incremental model, incremental_strategy='merge' on unique_key (the adapter default)
T-SQL MERGE (GA in the Warehouse) inside a stored procedure
Hand-rolled SCD2 history table
A dbt snapshot (timestamp or check strategy) producing dbt_valid_from / dbt_valid_to
MERGE-based effective-dating procedure that closes the old row and inserts the new
Dynamic SQL built and run with EXEC / sp_executesql
Jinja macro that templates the SQL at compile time, so the generated statement is reviewable
Runtime dynamic SQL kept inside a Warehouse procedure where templating won't fit
Cursor / WHILE loop, row-by-row
Rewritten as a single set-based model, the loop disappears into a join/window
Set-based rewrite in the procedure, or an explicit pipeline For Each where a loop is genuinely needed
Recursive CTE
Bounded iteration as staged models, since recursive queries aren't supported on the Warehouse
Pre-materialized hierarchy / iterative set-based steps in a procedure
Trigger (DML)
Logic moved into the model DAG and tests; no trigger equivalent exists
Explicit pipeline / procedure step, triggers are unsupported in the Warehouse
Cross-database three-part reference
ref() across models, including cross-warehouse references in the workspace
Cross-database query across warehouses / SQL analytics endpoints in the same workspace

Before & after

A real T-SQL snippet and what the builders generate on Microsoft Fabric.

dbo.usp_LoadDimCustomer.sql legacy
CREATE PROCEDURE dbo.usp_LoadDimCustomer
AS
BEGIN
  SET NOCOUNT ON;

  -- stage cleaned source rows
  SELECT
    c.CustomerId,
    UPPER(LTRIM(RTRIM(c.Name)))          AS Name,
    c.Segment,
    c.UpdatedAt
  INTO #stg
  FROM stage.Customer AS c
  WHERE c.UpdatedAt >= DATEADD(DAY, -3, SYSUTCDATETIME());

  -- SCD1 upsert into the dimension
  MERGE dbo.DimCustomer AS tgt
  USING #stg AS src
    ON tgt.CustomerId = src.CustomerId
  WHEN MATCHED AND (tgt.Name <> src.Name OR tgt.Segment <> src.Segment)
    THEN UPDATE SET tgt.Name = src.Name,
                    tgt.Segment = src.Segment,
                    tgt.UpdatedAt = src.UpdatedAt
  WHEN NOT MATCHED BY TARGET
    THEN INSERT (CustomerId, Name, Segment, UpdatedAt)
         VALUES (src.CustomerId, src.Name, src.Segment, src.UpdatedAt);

  DROP TABLE #stg;
END
dbt
models/marts/dim_customer.sql dbt
{{
  config(
    materialized = 'incremental',
    incremental_strategy = 'merge',
    unique_key = 'customer_id'
  )
}}

-- procedural batch -> a single set-based, tested model.
-- the staging proc became its own model; this one ref()s it.
select
    customer_id,
    upper(ltrim(rtrim(name)))            as name,
    segment,
    updated_at
from {{ ref('stg_customer') }}

{% if is_incremental() %}
  where updated_at >= dateadd(day, -3, sysutcdatetime())
{% endif %}
The loop that closes itself

Iterated until green, and logged.

A failure on this pair doesn't go on a list. It goes back to the builders, and the loop re-runs until the suite passes.

t-sql-to-fabric · iteration log build-test-operate
iter 1 iter 1, FAIL: `usp_LoadDimCustomer` used a recursive CTE for the org hierarchy; recursive queries are unsupported on the Warehouse. Re-expressed as staged set-based steps.
iter 2 iter 2, FAIL: a DML trigger on `DimCustomer` wrote an audit row; the Warehouse has no triggers. Audit move folded into the procedure / model with a test guarding it.
iter 3 iter 3, FAIL: dynamic `EXEC(@sql)` referenced `varchar(max)` joins that truncated; rebuilt as a compile-time macro and the parity check came back clean.
iter 4 iter 4, GREEN: row-count and aggregate delta 0 vs legacy; SCD effective dating and key integrity pass.

Green on iteration 4: recursive, trigger and dynamic-SQL gaps closed; delta 0 against the legacy procedure.

Questions for this migration

T-SQL → Microsoft Fabric, answered.

Can my T-SQL stored procedures move to Fabric as-is?

Mostly, but not blindly. The Fabric Warehouse supports `CREATE [OR ALTER] PROCEDURE` in a streamlined form, with parameters, `OUT`/`OUTPUT`, `#temp` tables and full multi-table transactions, so a lot of procedural T-SQL ports cleanly. What breaks is anything that leans on constructs outside the Warehouse surface area: triggers, recursive queries, `SELECT ... FOR XML`, `SET TRANSACTION ISOLATION LEVEL`, CLR/`EXTERNAL NAME` procedures. The Documenter flags those up front so they're redesigned at the design gate, not discovered at runtime.

How do you handle dynamic SQL?

It depends on whether the SQL is decided at compile time or at runtime. Logic that varies by configuration (column lists, environment names, table suffixes) is the natural fit for a dbt Jinja `macro`, which templates the statement at compile time so the generated SQL is reviewable in the build. Genuinely runtime dynamic SQL stays as dynamic T-SQL inside a Warehouse procedure. Either way the executed statement is reconstructed and documented, because in the legacy estate it never appears statically.

What about triggers and recursive CTEs?

Neither is supported on the Fabric Warehouse. Trigger logic is re-expressed as an explicit step: a procedure section, a pipeline activity, or a tested model that produces the same side effect. Recursive CTEs become bounded, staged set-based steps. Both are caught in the loop: if a build still depends on one, the run fails and routes the fix back to the builders rather than shipping a broken object.

Do enforced primary and foreign keys survive the move?

Not as enforced constraints. In the Warehouse, `PRIMARY KEY`, `UNIQUE` and `FOREIGN KEY` exist only as metadata with `NOT ENFORCED`, so the engine won't reject a duplicate or orphan on insert. We preserve the intent as data tests instead (`unique`, `not_null` and `relationships` on the dbt side, or equivalent assertions in your framework) so integrity is checked every run rather than assumed.

Why turn a stored procedure into a model DAG on the dbt path?

A long procedural batch hides its execution order inside imperative flow. Decomposed into models wired by `ref()`, that order becomes an explicit DAG dbt runs topologically: each step is idempotent, independently testable, and re-runnable without re-running everything before it. It also means a change to one staging step doesn't force a rebuild of the whole procedure, which is what makes the migrated logic maintainable rather than a transliterated black box.

Is dbt the only option, or can you build in our own framework?

Both. On the dbt path the builders generate a `dbt-fabric` project against the Warehouse: your layers, your macros, your tests. On the your-framework path they generate native Fabric: Warehouse T-SQL procedures, views and functions in your naming and error-handling conventions, orchestrated by Fabric data pipelines. Same source logic, same tests, the back end you already trust.

Let's talk

Ready to migrate T-SQL to Microsoft Fabric?

Tell us about your T-SQL estate and we'll run the assessment, the Surveyor scores it before you commit to Microsoft Fabric.

Plan my migration

A short form, no spam. We usually reply within one business day.

Plan my migration