Every morning the numbers are simply correct. Nobody wonders why. This is the freight yard that made it so — and the dispatcher who never sleeps.
Priya puts her coffee down and opens the board. Three hundred and forty stores, six distribution centres, tonight's truck manifests. Which pallets go where. What's short. What's about to be short by Thursday.
It is correct. All of it. Yesterday's tills, last night's supplier confirmations, this morning's stock positions, reconciled and sitting in one table as if they had always belonged together.
She does not wonder how. Nobody wonders how. That is the entire point.
Seven hours earlier, none of that data was in the same building — let alone the same shape. It was scattered across a dozen systems that don't know each other exist, run on different schedules, speak different formats, and fail in different ways. Something had to go and get it. Something had to decide the order, handle the ones that didn't show up, retry the ones that timed out, and finish before the sun did.
That something is Azure Data Factory. And the fastest way to understand what it is for is to look at the mess it was invented to replace.
Our company is Meridian Retail: 340 stores, 6 distribution centres, about 200 suppliers. Here is what has to arrive, every night, before Priya's coffee:
| Source | Where it lives | Its personality |
|---|---|---|
| Till transactions | On-prem SQL Server, in the datacentre | Dumps at 23:15 when the last store closes. Enormous. No route in from the internet. |
| Stock positions | Warehouse management system, on-prem | Ancient, precious, and guarded by a team who will not be giving you a firewall exception. |
| Supplier confirmations | ~200 SFTP drops, CSV | Arrive between 22:00 and 05:00 on nobody's schedule. Occasionally malformed. Occasionally not at all. |
| Carrier tracking | Third-party REST APIs | Paginated, rate-limited, and will happily 429 you at 02:00. |
| E-commerce orders | Azure SQL | The one that behaves. |
| Finance & cost | SAP | Month-end shaped. Different owner. Different rules. |
Six sources, one destination, one deadline. That sounds like six jobs. It is not — and the reason it is not is the whole reason this category of tool exists.
The main text is the film — plain English, no prerequisites. Anywhere you see an Under the hood panel, open it for the real pipeline JSON, the actual settings, and the numbers. Skip every one of them and the story still works.
The instinct is to write a script per route. One to pull tills into the lake. One to pull stock. One for each supplier feed. Small, understandable, independent. It works beautifully — right up until it doesn't, and then it fails in a way that is very hard to climb back out of.
There are two problems, and the second one is the one that actually kills you.
The first is arithmetic. Sources multiply by destinations. The lake wants raw tills; the warehouse model wants cleaned tills; finance wants an aggregate; the ML forecaster wants its own slice. Every new destination re-crosses every existing source. Move the slider below and watch the line count go somewhere unpleasant.
The second problem is the one that gets you paged. Every one of those scripts invents its own answer to the same six questions — and invents it slightly differently.
while true loop in a service somewhere nobody remembers deploying.It is 03:00. A number on the board is wrong. Somewhere among two hundred scripts on four machines, one of them either didn't run, ran twice, ran early, or ran against a source that was mid-backup and returned half a table.
You do not have a data problem. You have an archaeology problem.
So the job to be done isn't "copy data." Copying is easy; every language has a library for it. The job is everything around the copying — the ordering, the timing, the waiting, the retrying, the alerting, the record of what happened and when. That's not plumbing. That's dispatch.
Azure Data Factory exists so that the answers to "when does this run, in what order, what happens when it breaks, and who finds out" are written down in one place, in one way, for every route in the yard — instead of two hundred times, slightly differently, by whoever happened to be on that ticket.
Here is the single most clarifying thing you can know about Azure Data Factory, and it is the thing most introductions bury: ADF is a dispatcher, not a lorry. Its job is to decide what moves, in what order, when, and what happens when something doesn't arrive. It is a control system first and a hauler second.
That distinction matters because it tells you what to expect. ADF does shift bulk data itself — its Copy activity is a genuinely fast, heavily parallel mover, and for most ingestion that's all you need. But when the work gets computational — joins across billions of rows, complex reshaping, machine learning — ADF does what a good dispatcher does: it hands the job to something built for it (Databricks, a SQL engine, an Azure Function) and goes back to watching the board. It can also run that transformation on Spark it manages for you, which we'll get to.
A dispatcher's value is not muscle. It is that at 02:00 exactly one person knows what is on which track, what is late, what is blocked behind what, and which siding the 400-tonne thing is waiting in.
Take the dispatcher away and you still have all the trains. You just have no railway.
Six nouns run the whole product. Click through them — every one is either something you will configure on your first day or something you will be reading about at 3am on some later one.
Five of those six nouns are intuitive. The one that quietly decides whether your project succeeds is the integration runtime — because it is the answer to a question nobody thinks to ask until the security review: whose network is this actually happening on?
Meridian's till database is in a datacentre, behind a firewall, and the network team will not be opening an inbound port so that a cloud service can reach in. That is not obstruction; that is correct. So ADF doesn't reach in. You install a self-hosted integration runtime on a machine inside that network, and it dials out to Azure on 443. The connection is established from the trusted side. Nothing inbound is ever opened.
The self-hosted IR needs only outbound HTTPS — to Azure Relay for control messages and to the Data Factory endpoint. Credentials are encrypted and stored on that machine, not in the cloud. You can run up to four nodes for high availability and throughput. From the security team's point of view, you installed an agent that phones home; you did not punch a hole in the perimeter. That difference is why hybrid data integration is possible at all.
Everything in ADF is JSON underneath, whether or not you ever open the code view. A linked service is a connection — note that the secret is a Key Vault reference, never a literal:
{
"name": "OnPremTills",
"properties": {
"type": "SqlServer",
// which runtime executes this — i.e. whose network
"connectVia": { "referenceName": "shir-datacentre", "type": "IntegrationRuntimeReference" },
"typeProperties": {
"server": "sql-tills-01.meridian.internal",
"database": "TillsWarehouse",
"authenticationType": "SQL",
"userName": "svc_adf_reader",
"password": {
"type": "AzureKeyVaultSecret",
"store": { "referenceName": "kv-meridian", "type": "LinkedServiceReference" },
"secretName": "tills-reader-password"
}
}
}
}
A dataset names a shape of data at that place. Keep them generic and parameterised — one dataset for "a table in tills", not forty datasets for forty tables:
{
"name": "TillsTable",
"properties": {
"type": "SqlServerTable",
"linkedServiceName": { "referenceName": "OnPremTills", "type": "LinkedServiceReference" },
// parameters are what stop you building 40 near-identical datasets
"parameters": {
"schemaName": { "type": "string" },
"tableName": { "type": "string" }
},
"typeProperties": {
"schema": { "value": "@dataset().schemaName", "type": "Expression" },
"table": { "value": "@dataset().tableName", "type": "Expression" }
}
}
}
The parameterised dataset is the difference between a project that scales and one that doesn't. Teams that skip it end up hand-building one dataset, one pipeline and one trigger per table, and by table ninety nobody can change anything safely. We'll build the alternative — one pipeline driven by a control table — in Act IV.
A dispatcher's first decision is when. ADF gives you three fundamentally different answers, and choosing wrong is the single most common cause of the 03:00 phone call — because two of them look identical until the night something goes wrong.
The schedule trigger is a wall clock. Every day at 23:30, run this. Simple, familiar, and what most people reach for first.
The tumbling window trigger is a series of contiguous, non-overlapping time
slices — 23:00–00:00, 00:00–01:00, and so on, forever, with no gaps. Each run knows which slice
it owns, because ADF hands it WindowStart and WindowEnd as parameters.
The event trigger doesn't watch a clock at all. It watches a storage account, and fires when a blob appears. This is how you handle two hundred suppliers who upload whenever they feel like it: you stop polling and start reacting.
Run the night below. Let it play, then break it — knock out the middle of the night with an outage and watch what each lane does about the runs it missed.
That's the whole lesson, and it costs people real money. When the source came back at 03:00, the schedule trigger simply moved on. Its 02:30 run happened during the outage, failed, and that slice of the night is now gone — nothing will ever go back for it unless a human notices and does it by hand.
The tumbling window trigger walked back and ran the windows it had missed, in order. It could do that because it keeps state: it knows which windows exist, which have succeeded, and which haven't. That's also why it supports backfill in general — set the start date to last month and it will work through every window since, honouring however much concurrency you allow it.
| Schedule | Tumbling window | |
|---|---|---|
| Backfill the past | No — future times only | Yes, that's the point |
| Built-in retry | No | Yes, configurable |
| Concurrency control | No | Yes, 1–50 |
| Knows its time slice | Only "when it fired" | WindowStart / WindowEnd |
| Pipelines per trigger | Many-to-many | Strictly one-to-one |
| When is it "successful"? | When the run starts | When the run finishes |
| Depends on other windows | No | Yes |
A schedule trigger is fire-and-forget: it reports success once it has launched the pipeline. The pipeline can fail thirty seconds later and the trigger's own history still says it did its job. A tumbling window trigger waits for the pipeline and takes on its outcome. If you have ever looked at a green trigger history while the data was plainly missing, this is the row that explains it.
A useful rule: if the run is about a period of time, use a tumbling window. "Yesterday's sales", "last hour's transactions", "the 03:00–04:00 slice" — anything where a run owns a slice and a missed slice leaves a hole. Use a schedule trigger for work that is genuinely about the wall clock rather than a period — a nightly full refresh, a 07:00 report send — where missing one occurrence is an inconvenience, not a gap in a table.
{
"name": "TillsHourly",
"properties": {
"type": "TumblingWindowTrigger",
"typeProperties": {
"frequency": "Hour",
"interval": 1,
"startTime": "2026-08-01T22:00:00Z",
// how many missed windows may run at once when backfilling
"maxConcurrency": 4,
"retryPolicy": { "count": 3, "intervalInSeconds": 300 },
// don't start this window until yesterday's same window succeeded
"dependsOn": [{
"type": "TumblingWindowTriggerDependencyReference",
"referenceTrigger": { "referenceName": "TillsHourly", "type": "TriggerReference" },
"offset": "-24:00:00", "size": "01:00:00"
}]
},
"pipeline": {
"pipelineReference": { "referenceName": "LoadTills", "type": "PipelineReference" },
// ← THIS is the important part
"parameters": {
"windowStart": "@trigger().outputs.windowStartTime",
"windowEnd": "@trigger().outputs.windowEndTime"
}
}
}
}
The pipeline then filters on exactly its own slice, which is what makes a rerun harmless:
-- inside the Copy activity's source query
SELECT * FROM dbo.TillTransactions
WHERE ModifiedUtc >= '@{pipeline().parameters.windowStart}'
AND ModifiedUtc < '@{pipeline().parameters.windowEnd}';
Note the asymmetry: >= on the start, < on the end.
Get that wrong and every boundary row is either loaded twice or missed entirely — a bug that
surfaces as "the daily total is off by a rounding error" and takes a week to find.
The dependsOn block is the underrated feature. It lets a window wait on
another window — including an earlier window of the same trigger — so a backfill of three
days' worth of hourly loads runs in the right order instead of forty runs stampeding your
source database simultaneously.
The Copy activity is the workhorse, and it is a good deal cleverer than it looks. It has two dials worth understanding, and one mental model that will save you from turning them pointlessly.
The first dial is Data Integration Units — DIUs. A DIU is an abstract bundle of CPU, memory and network on the Azure integration runtime. You can request anywhere from 4 to 256 of them, and by default ADF picks a number for you based on what you're copying from and to. More DIUs means more machine pointed at the problem.
The second is degree of parallelism — parallelCopies — the number
of concurrent threads reading from the source and writing to the sink. For a folder of two
thousand supplier CSVs, that's how many files move at once. For a partitioned table, it's how
many partitions stream in parallel.
Throughput is not what you provisioned. Throughput is the narrowest point on the route — whichever is smallest of: what the source is willing to hand over, what the network can carry, what the sink will accept, and what you paid for.
Turning up the dial on the widest part of the route costs money and changes nothing.
Play with it. Try to make it fast, then try to work out which lever actually did it.
The un-partitioned table is the one that teaches the lesson. A single table with no partition column is a single stream — one reader, one connection, one queue. You can request 256 DIUs and 48 parallel copies and the source will still hand you one row at a time in one order. The fix is not a bigger dial; it's giving the source something to parallelise on — a partition column, a date range, a set of key ranges.
Sometimes the right move is to copy in two stages: source → blob storage → sink. It sounds slower and often isn't. Enable compression and a slow on-prem link carries far fewer bytes. Some sinks — Synapse via PolyBase, Snowflake, Redshift — are dramatically faster loading from blob than accepting a row stream. And when a firewall only permits 443, staging is what makes the route possible at all. ADF cleans up the interim files itself when the copy completes.
{
"name": "CopyTillsToLake",
"type": "Copy",
"policy": { "retry": 3, "retryIntervalInSeconds": 120, "timeout": "02:00:00" },
"typeProperties": {
"source": {
"type": "SqlServerSource",
"sqlReaderQuery": {
"value": "SELECT * FROM dbo.TillTransactions WHERE ModifiedUtc >= '@{pipeline().parameters.windowStart}' AND ModifiedUtc < '@{pipeline().parameters.windowEnd}'",
"type": "Expression"
},
// give the source something to parallelise on
"partitionOption": "PhysicalPartitionsOfTable"
},
"sink": {
"type": "ParquetSink",
"storeSettings": { "type": "AzureBlobFSWriteSettings" }
},
// omit these two and ADF chooses for you — which is usually right
"dataIntegrationUnits": 32,
"parallelCopies": 8,
// two-hop through blob, compressed — worth it over a slow link
"enableStaging": true,
"stagingSettings": {
"linkedServiceName": { "referenceName": "stgMeridian", "type": "LinkedServiceReference" },
"path": "adf-staging",
"enableCompression": true
}
}
}
Start by setting neither dial. ADF's automatic choice is genuinely good and adapts to the shape of each run. Set them only when you have measured a specific run and know which way you need to push it — and remember DIUs apply to the Azure integration runtime only. Copies executed by a self-hosted IR are bounded by that machine's cores, memory and link, which is why the node slider above matters more than the DIU one when the source is on-prem.
Billing follows the same shape as the dials: data movement is charged per DIU-hour, so used DIUs × how long the copy ran. Doubling DIUs on a route that then finishes in half the time costs roughly the same. Doubling DIUs on a route that doesn't speed up at all costs exactly double. Rates vary by region — the Azure pricing calculator is the only honest source for a number.
Here is where teams either discover the pattern that makes ADF scale, or quietly build themselves a maintenance nightmare that takes two years to admit to.
The nightmare version is reasonable-looking: one pipeline per table. Table one gets a pipeline, a dataset, a trigger. So does table two. By table two hundred you have six hundred objects, no two quite alike, and a change to the retry policy is a fortnight of clicking.
The pattern that works is metadata-driven: one pipeline, one parameterised dataset, and a control table that lists what to load. Want to add a table? Insert a row. The pipeline doesn't change. Neither does the trigger. Nothing gets clicked.
Stop drawing one pipeline per thing you load. Draw one pipeline that reads a list, then does the same careful thing to every item on it.
Your pipelines stop being a picture of your data estate and become a description of your process. The estate lives in a table, where it can be queried, audited and changed by a person who has never opened Data Factory.
Below is that pipeline, running. The Lookup reads the control table; the ForEach fans out across every table it found. Change the batch count and watch the wall-clock time move.
Notice the trade in the batch count. One at a time is gentle and slow. Twenty at a time is fast right up until your source database notices twenty concurrent extract queries and starts refusing connections — or your on-prem IR machine runs out of memory. The batch count is not a performance setting; it is a politeness setting, and the correct value is whatever the system on the other end can tolerate at 02:00 without paging its own owner.
Everything so far has been movement. Real pipelines also reshape — join tills to products, deduplicate supplier rows, pivot a week into columns. ADF gives you two honest options.
Mapping data flows let you draw the transformation — join, aggregate, derive, pivot — and ADF compiles it to Spark and runs it on a cluster it manages. You never write Scala and you never manage a cluster. The costs to know: a cold cluster takes a few minutes to start before any of your data moves, and clusters are billed per vCore-hour with a floor of 8 vCores. That startup is why a pipeline with six sequential data flows can spend more time booting than working — and why the integration runtime's time-to-live setting exists, keeping a cluster warm between sequential runs so the second flow doesn't pay the startup toll again.
Or delegate. If the transformation already lives somewhere — a stored procedure your DBAs trust, a Databricks notebook your data scientists own, a Function that calls a pricing API — ADF calls it, waits, and handles the failure. That's the dispatcher doing its actual job. A pipeline made mostly of "go ask that system to do the thing it's good at" is not a cop-out; it's frequently the correct architecture.
The control table is unglamorous and it is the most valuable object in the whole solution:
CREATE TABLE ctl.LoadRegistry (
LoadId INT IDENTITY PRIMARY KEY,
SourceSchema SYSNAME NOT NULL,
SourceTable SYSNAME NOT NULL,
SinkPath NVARCHAR(400) NOT NULL,
WatermarkColumn SYSNAME NOT NULL,
LastWatermark DATETIME2 NOT NULL, -- how far we got last time
BusinessKey NVARCHAR(200) NOT NULL, -- what makes a row unique
IsEnabled BIT NOT NULL DEFAULT 1,
LoadGroup TINYINT NOT NULL DEFAULT 1 -- lets you stagger the night
);
And the pipeline that consumes it. Note isSequential: false with an explicit
batchCount — that pair is the politeness setting discussed above:
{
"name": "LoadEverything",
"properties": {
"parameters": { "loadGroup": { "type": "int", "defaultValue": 1 } },
"activities": [
{
"name": "GetTableList",
"type": "Lookup",
"typeProperties": {
"source": {
"type": "AzureSqlSource",
"sqlReaderQuery": "SELECT * FROM ctl.LoadRegistry WHERE IsEnabled = 1 AND LoadGroup = @{pipeline().parameters.loadGroup}"
},
"firstRowOnly": false // ← forget this and you get exactly one table
}
},
{
"name": "ForEachTable",
"type": "ForEach",
"dependsOn": [{ "activity": "GetTableList", "dependencyConditions": ["Succeeded"] }],
"typeProperties": {
"items": { "value": "@activity('GetTableList').output.value", "type": "Expression" },
"isSequential": false,
"batchCount": 6, // be kind to the source
"activities": [
{
"name": "CopyOneTable",
"type": "ExecutePipeline",
"typeProperties": {
"pipeline": { "referenceName": "LoadOneTable", "type": "PipelineReference" },
"waitOnCompletion": true,
"parameters": {
"schemaName": "@item().SourceSchema",
"tableName": "@item().SourceTable",
"watermarkColumn": "@item().WatermarkColumn",
"lastWatermark": "@item().LastWatermark",
"businessKey": "@item().BusinessKey",
"sinkPath": "@item().SinkPath"
}
}
}
]
}
}
]
}
}
Two details that bite people. firstRowOnly defaults to true on a
Lookup, so a forgotten false silently loads one table and reports success.
And a Lookup returns at most a few thousand rows — for a genuinely large control set you
page it or drive the ForEach from a stored procedure instead.
It always goes wrong somewhere. A supplier ships a CSV with an unescaped comma in a product description. The on-prem SQL box starts its backup window and stops answering. A carrier API decides you've asked enough questions for one night and starts returning 429.
None of this is exceptional. All of it is Tuesday. The measure of a pipeline is not whether it fails — it's what the failure costs you.
ADF gives you three tools, and then asks you one question that it cannot answer on your behalf.
The tools: retry policies per activity, because most 3am failures are transient and a second attempt four minutes later just works. Failure paths, because activity dependencies aren't only "then" — they're Succeeded, Failed, Completed and Skipped, so you can wire a real handler that logs the error, writes the bad file to a quarantine folder, and raises an alert. And rerun from the failed activity, so a pipeline that died on step nine of eleven doesn't re-copy four hundred gigabytes to get back to where it was.
If I run this again, do I get the same answer — or do I get the answer twice?
That property is idempotency, and it is not a setting. It's a design decision you make in how you write to the sink. Get it right and reruns are boring. Get it wrong and every incident has a second, worse incident hiding inside it — the cleanup.
Break the night below, both ways.
Same failure. Same rerun. Two completely different mornings — and the difference was decided weeks earlier, by whoever chose how the sink gets written.
The append version is not stupid; it's just optimistic. It assumes every run happens exactly once, which is true until the first time it isn't. The upsert version makes a stronger promise: running me twice is the same as running me once. That promise is what lets you retry automatically, backfill freely, and rerun at 03:00 without first having to reason about what's already in the table.
Three things, and you need all three. A business key — the thing that makes a row genuinely unique, not an auto-increment surrogate. A time slice that the run owns, so it knows exactly which rows are its responsibility. And a write that replaces rather than adds — an upsert/merge, or a delete-then-insert of just that slice. Miss the key and you can't match. Miss the slice and you don't know what to replace. Miss the merge and you're appending.
A failure nobody hears about is just a slower, more expensive kind of data loss. ADF emits metrics and run logs to Azure Monitor, so the useful setup is small and worth doing on day one: alert on pipeline failed, alert on pipeline elapsed time above the usual (a run that used to take twenty minutes and now takes ninety is a real signal that nothing else catches), and route both to the place people actually look. Then keep the run history somewhere that outlives ADF's own retention, because the question "what did this pipeline do six weeks ago?" arrives eventually and it always arrives urgently.
{
"name": "CopySupplierFile",
"type": "Copy",
// most 3am failures are transient — try again before waking anyone
"policy": { "retry": 3, "retryIntervalInSeconds": 240, "timeout": "01:00:00" }
},
{
"name": "QuarantineBadFile",
"type": "Copy",
// runs ONLY if the copy above exhausted its retries and failed
"dependsOn": [{ "activity": "CopySupplierFile", "dependencyConditions": ["Failed"] }]
},
{
"name": "RecordOutcome",
"type": "SqlServerStoredProcedure",
// runs either way — this is how the control table learns what happened
"dependsOn": [{ "activity": "CopySupplierFile", "dependencyConditions": ["Completed"] }]
}
Completed is the one people forget exists. It fires on success or
failure, which is exactly what you want for logging, watermark bookkeeping and cleanup.
Land the slice in a staging table, then merge on the business key. Re-running replaces rather than duplicates, so the second run is a no-op:
CREATE PROCEDURE ctl.MergeTills
@WindowStart DATETIME2,
@WindowEnd DATETIME2
AS
BEGIN
SET XACT_ABORT ON;
BEGIN TRAN;
MERGE dwh.TillTransactions AS tgt
USING (SELECT * FROM stg.TillTransactions
WHERE ModifiedUtc >= @WindowStart AND ModifiedUtc < @WindowEnd) AS src
ON tgt.StoreId = src.StoreId -- the business key,
AND tgt.TillId = src.TillId -- not an identity column
AND tgt.TxnNumber = src.TxnNumber
WHEN MATCHED THEN UPDATE SET
tgt.Amount = src.Amount, tgt.ModifiedUtc = src.ModifiedUtc
WHEN NOT MATCHED BY TARGET THEN INSERT (...) VALUES (...);
-- only advance the watermark once the merge has committed
UPDATE ctl.LoadRegistry
SET LastWatermark = @WindowEnd
WHERE SourceTable = 'TillTransactions';
COMMIT;
END
The watermark update belongs inside the same transaction as the merge. Advance it first and a failure between the two steps means you've recorded progress you didn't make — and that gap will never be filled by anything except a human noticing.
Because at 22:00 a trigger fired. Because a pipeline read a list rather than hard-coding one. Because an agent inside the datacentre dialled out instead of a firewall being opened. Because each run owned a slice of time and knew it. Because when the supplier file was malformed, three retries happened, a quarantine folder filled, an alert went out, and the other hundred and ninety-nine files carried on. Because when someone reran the failed step at 03:10, the merge replaced rather than doubled.
And because none of that was invented that night. It was decided once, written down, and applied to every route in the yard.
Azure Data Factory is not really a data-moving tool. It's a place to put the answers — to when, in what order, how many at a time, what if it fails, who finds out, and what happened last night — so that those answers exist once instead of two hundred times.
The data movement is the easy part. It was always the easy part.
| # | Do this | Not this |
|---|---|---|
| 1 | Parameterise datasets from the very first one | One dataset per table, forever |
| 2 | A control table driving one pipeline | A pipeline per table |
| 3 | Tumbling windows for anything that owns a time slice | Schedule triggers everywhere by habit |
| 4 | Design the idempotent write before the first load | Discovering duplicates during an incident |
| 5 | Secrets in Key Vault, referenced — never typed in | A password in a linked service |
| 6 | Leave DIU and parallelism on auto until you've measured | Turning dials on the widest part of the route |
| 7 | Failure alerts and a duration alert on day one | Finding out from Priya at 06:40 |
| 8 | Git-backed from the start, ARM templates deployed by a pipeline | Editing production in the portal |
Connect the factory to Git on day one, before there is anything to lose. Development happens
on branches; merging to main is what makes a change real. The modern deployment path skips the
old "click Publish in the portal" step entirely — a build pipeline validates the factory and
exports the ARM template programmatically via Microsoft's
@microsoft/azure-data-factory-utilities npm package, and a release pipeline
deploys that artifact to test and production. The portal stops being where you ship and goes
back to being where you look.
Every diagram on this page is a live simulation, not a screenshot. Diagram 4's throughput figures are an illustrative model built to teach where bottlenecks live — not a capacity planner; measure your own runs. Behaviour described here reflects Azure Data Factory as of August 2026: DIUs range 4–256, tumbling window concurrency 1–50, self-hosted integration runtime up to 4 nodes. Pricing is charged per activity run, per DIU-hour and per data-flow vCore-hour, with rates that vary by region — check the calculator rather than trusting any number in a blog post, including this one.