Metadata-Driven Pipelines in Azure Data Factory
A metadata-driven pipeline reads a list of what to copy from a control table and applies one generic Copy activity to every row. This guide explains the building blocks in Azure Data Factory, shows the pattern working on real tables, and walks through the pipelines and control tables the Copy Data tool generates.
GloryTecks Team
Training & Placement Faculty
Last updated: 2 October 2026
The first table takes an afternoon: a dataset, another dataset, a pipeline, a Copy activity. The second takes twenty minutes, by cloning the first. By the fortieth, the factory holds eighty datasets and forty pipelines that differ only in a table name, and a change to error handling has to be made forty times. Metadata-driven pipelines replace the forty with one.
The idea
Separate what to copy from how to copy it.
- The what is data: a control table with one row per table or folder, naming the source, the destination and how to load it.
- The how is one generic pipeline that reads those rows and does the same thing for each.
Adding a table becomes inserting a row. Microsoft makes the same case for the approach: you maintain the list of objects by updating the control table instead of redeploying pipelines, and you have a single place to see what is copied and how (Microsoft Learn: metadata-driven copy).
The building blocks
Four features of Azure Data Factory make the pattern possible:
| Block | Role |
|---|---|
| Lookup activity | Reads the control table and returns its rows |
| ForEach activity | Runs its inner activities once per row |
| Parameterized datasets | Take the schema, table or folder name as parameters instead of fixed values |
| Dynamic content | Expressions that read the current row, such as the table name |
Microsoft's bulk copy tutorial builds exactly this shape (Microsoft Learn: copy data in bulk). The skeleton:
{
"name": "CopyAllFromControlTable",
"properties": {
"activities": [
{
"name": "GetObjects",
"type": "Lookup",
"typeProperties": {
"source": {
"type": "AzureSqlSource",
"sqlReaderQuery": "SELECT SourceSchema, SourceTable, SinkFolder FROM etl.Control WHERE CopyEnabled = 1"
},
"dataset": { "referenceName": "ControlDb", "type": "DatasetReference" },
"firstRowOnly": false
}
},
{
"name": "ForEachObject",
"type": "ForEach",
"dependsOn": [ { "activity": "GetObjects", "dependencyConditions": [ "Succeeded" ] } ],
"typeProperties": {
"items": { "value": "@activity('GetObjects').output.value", "type": "Expression" },
"isSequential": false,
"batchCount": 10,
"activities": [
{
"name": "CopyOneObject",
"type": "Copy",
"inputs": [ {
"referenceName": "SourceTableGeneric",
"type": "DatasetReference",
"parameters": {
"SchemaName": { "value": "@item().SourceSchema", "type": "Expression" },
"TableName": { "value": "@item().SourceTable", "type": "Expression" }
}
} ],
"outputs": [ {
"referenceName": "LakeFolderGeneric",
"type": "DatasetReference",
"parameters": { "FolderPath": { "value": "@item().SinkFolder", "type": "Expression" } }
} ],
"typeProperties": {
"source": { "type": "SqlSource" },
"sink": { "type": "ParquetSink" }
}
}
]
}
}
]
}
}Three expressions carry the whole design. @activity('GetObjects').output.value is the array of rows. @item() is the current row inside the loop. Inside each generic dataset, @dataset().TableName reads the parameter it was given. Parameterizing pipelines and Lookup and Get Metadata cover these in detail.
Note firstRowOnly set to false. Its default is true, which returns a single row and is the most common reason a metadata-driven pipeline copies one table and stops.
The pattern, working
The script below reproduces the pattern in SQLite on the Chinook sample database (Chinook on GitHub). A control table lists six tables: four loaded in full, one of which is switched off, and two loaded as deltas by a watermark column. One routine copies whatever its row describes. Between the two runs the source gains new invoices, and a seventh table is added by inserting one control row. It is a local reproduction, not Data Factory.
import shutil, sqlite3
shutil.copy("chinook.sqlite", "source.sqlite") # a working copy stands in for the source system
db = sqlite3.connect(":memory:") # the destination, which also holds the control table
db.execute("ATTACH DATABASE 'source.sqlite' AS src")
db.executescript("""
CREATE TABLE control (
Id INTEGER PRIMARY KEY, SourceTable TEXT, SinkTable TEXT,
LoadType TEXT CHECK (LoadType IN ('full', 'delta')),
WatermarkColumn TEXT, WatermarkValue INTEGER DEFAULT 0,
TaskId INTEGER DEFAULT 0, CopyEnabled INTEGER DEFAULT 1);
INSERT INTO control (SourceTable, SinkTable, LoadType, WatermarkColumn, TaskId, CopyEnabled) VALUES
('Artist', 'stg_artist', 'full', NULL, 0, 1),
('Album', 'stg_album', 'full', NULL, 0, 1),
('Genre', 'stg_genre', 'full', NULL, 0, 0),
('Customer', 'stg_customer', 'full', NULL, 5, 1),
('Invoice', 'stg_invoice', 'delta', 'InvoiceId', 0, 1),
('InvoiceLine', 'stg_invoice_line', 'delta', 'InvoiceLineId', 0, 1);
""")
def copy_one(item): # one generic "Copy activity", driven entirely by its row
item_id, source, sink, load_type, column, old = item
if load_type == "full":
db.execute(f"DROP TABLE IF EXISTS {sink}")
db.execute(f"CREATE TABLE {sink} AS SELECT * FROM src.{source}")
return db.execute(f"SELECT COUNT(*) FROM {sink}").fetchone()[0]
new = db.execute(f"SELECT MAX({column}) FROM src.{source}").fetchone()[0]
db.execute(f"CREATE TABLE IF NOT EXISTS {sink} AS SELECT * FROM src.{source} WHERE 0")
copied = db.execute(f"INSERT INTO {sink} SELECT * FROM src.{source} WHERE {column} > ? AND {column} <= ?",
(old, new)).rowcount
db.execute("UPDATE control SET WatermarkValue = ? WHERE Id = ?", (new, item_id))
return copied
def run_pipeline(label):
items = db.execute("""SELECT Id, SourceTable, SinkTable, LoadType, WatermarkColumn, WatermarkValue
FROM control WHERE CopyEnabled = 1 ORDER BY TaskId DESC, Id""").fetchall() # the Lookup
print(f"{label}: {len(items)} objects")
for item in items: # the ForEach
print(f" {item[1]:12} {item[3]:6} {copy_one(item):5} rows")
db.commit()
run_pipeline("run 1")
db.executescript("""
INSERT INTO src.Invoice (InvoiceId, CustomerId, InvoiceDate, Total) VALUES (413, 5, '2014-01-05', 9.90), (414, 7, '2014-01-05', 1.98);
INSERT INTO src.InvoiceLine (InvoiceLineId, InvoiceId, TrackId, UnitPrice, Quantity)
VALUES (2241, 413, 1, 0.99, 10), (2242, 414, 2, 0.99, 1), (2243, 414, 3, 0.99, 1);
-- onboarding a table is one row of metadata, and no change to the pipeline
INSERT INTO control (SourceTable, SinkTable, LoadType) VALUES ('Employee', 'stg_employee', 'full');
""")
run_pipeline("run 2")run 1: 5 objects
Customer full 59 rows
Artist full 275 rows
Album full 347 rows
Invoice delta 412 rows
InvoiceLine delta 2240 rows
run 2: 6 objects
Customer full 59 rows
Artist full 275 rows
Album full 347 rows
Invoice delta 2 rows
InvoiceLine delta 3 rows
Employee full 8 rowsRead the two runs side by side:
- Five objects, not six. Genre is in the control table and disabled, so the Lookup's query never returns it. Switching a load off is an update, not a deployment.
- Order follows the metadata. Customer has the highest TaskId and goes first.
- Full and delta share one routine. In run 2 the full tables are reloaded in their entirety, while Invoice and InvoiceLine copy only 2 and 3 new rows, because each row carries its own watermark and the routine updates it.
- Employee appears in run 2. Nothing in the pipeline changed. A row was inserted.
That last line is the argument for the pattern. The table names in such a routine come from the control table, so who may write to that table matters; treat it as code.
The limits that shape real designs
Two documented limits decide how the pattern scales.
Lookup returns at most 5,000 rows and 4 MB (Microsoft Learn: Lookup activity). A control table with more objects than that has to be read in pages.
ForEach runs at most 50 iterations at once, with the batch count as the upper limit (Microsoft Learn: ForEach activity). The loop cannot contain another ForEach, so nested iteration means calling a child pipeline. Control flow in ADF covers both points.
There is a third limit of a different kind. Not everything can be a parameter. Microsoft lists it among the known limitations of its own generator: the integration runtime name, the database type and the file format type cannot be parameterized. One generic pipeline can serve every SQL Server table; an Oracle source needs a second pipeline, though both can share one control table.
What the Copy Data tool generates
You do not have to build this by hand. The Copy Data tool has a metadata-driven copy task that produces the pipelines and the SQL scripts for the control tables. Seeing what it generates is instructive even if you later write your own.
Two control tables. The main control table has one row per object. Its columns hold the source settings, the sink settings and the copy settings as JSON, the loading behaviour, full or delta, and two columns used in the reproduction above: TaskId, which orders the objects, highest first, and CopyEnabled, 1 or 0. A second table holds connection settings when a parameterized linked service is used. A generated stored procedure writes the new watermark back after each delta load.
Three levels of pipeline.
| Level | What it does |
|---|---|
| Top | Counts the objects to copy and divides them into sequential batches, according to the maximum number of concurrent copy tasks |
| Middle | Takes one batch and divides it into groups, to stay under the Lookup output limit |
| Bottom | Loops over the objects in one group and, for each, runs a Switch: a full copy, or a delta copy with a watermark lookup and update |
The three levels are a direct answer to the two limits above. The default number of concurrent copy tasks is 20.
Updating it. The tool can reopen the control table for editing and produce a new SQL script. The pipelines are not redeployed; only the table changes.
Documented limitations. Besides the three properties that cannot be parameterized: the generated scripts use OPENJSON, so a SQL Server hosting the control table must be 2016 or later, and for storage sources only full load is offered, not incremental loading of new files.
When the pattern is worth it
| Situation | Approach |
|---|---|
| Three or four tables that will not grow | Separate pipelines are fine |
| Dozens of tables loaded the same way | Metadata-driven |
| Sources added every month | Metadata-driven |
| Tables that each need different transformation logic | Metadata-driven for ingestion to a raw layer; specific logic afterwards |
| Loads with unique, complex rules | Their own pipelines |
The pattern suits ingestion, where the work really is the same for every table. It suits business transformation badly, where every table is different.
Trade-offs to accept knowingly
- The pipeline is harder to read. Every property is an expression. A newcomer cannot see what is copied without querying the control table.
- One run holds many loads. Monitoring shows one pipeline run with hundreds of activity runs. Per-table success and timing need to be recorded somewhere you can query.
- A bad row breaks at run time. A mistyped table name is not caught at publish.
- A failure needs a rerun plan. Rerunning the whole loop repeats the tables that succeeded unless the design tracks state.
Those last three are what a framework around the pattern is for: building a metadata framework for ADF pipelines covers the design. The delta logic is explained in incremental data loading.
What is a metadata-driven pipeline in Azure Data Factory?
Which activities does the pattern use?
How many tables can one metadata-driven pipeline handle?
Can one pipeline copy from both SQL Server and Oracle?
Azure Data Factory and reusable pipeline design are covered in the Data Engineering course.
Sources
- Build large-scale data copy pipelines with metadata-driven approach in copy data tool (Microsoft Learn) — Microsoft Learn
- Copy data in bulk using Azure portal (Microsoft Learn) — Microsoft Learn
- Lookup activity in Azure Data Factory and Azure Synapse Analytics (Microsoft Learn) — Microsoft Learn
- ForEach activity in Azure Data Factory and Azure Synapse Analytics (Microsoft Learn) — Microsoft Learn
- Chinook sample database (GitHub) — GitHub
GloryTecks Team
Training & Placement Faculty
The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.
Want to master Azure Data Factory?
Join GloryTecks’ expert-led Data Engineering training in Hyderabad with real-time projects and 100% placement support.
Related articles
Azure Data Factory Roadmap 2026: A Complete Guide
A staged roadmap for learning Azure Data Factory in 2026: the six building blocks and how they relate, five stages from a first copy pipeline to deployed and monitored pipelines, a checkpoint for each stage, how Microsoft Fabric changes the picture, the state of the certifications, and the skills ADF does not replace.
ADF Pipelines Explained: Building Your First Pipeline
A beginner's guide to pipelines in Azure Data Factory: what a pipeline and an activity are, the three kinds of activity, the objects you create for a first copy from blob storage to a SQL table, the JSON behind the canvas, why Debug moves real data, what Publish does, and how to read a run's output.
Linked Services in Azure Data Factory Explained
A linked service is how Azure Data Factory connects to a data store or a compute service. This guide covers the two kinds, the anatomy of the JSON, authentication from keys to managed identities, keeping secrets in Key Vault, parameterising one linked service for many databases, and handling development and production.