Datanest Digital — datanest.dev
This guide covers production patterns for orchestrating Databricks notebooks from Azure Data Factory. It addresses cluster strategy, parameter passing, error handling, medallion architecture orchestration, and operational considerations.
Managed identity is the most secure approach — no tokens or credentials to manage.
{
"type": "AzureDatabricks",
"typeProperties": {
"domain": "https://adb-<id>.azuredatabricks.net",
"authentication": "MSI",
"workspaceResourceId": "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Databricks/workspaces/<ws>"
}
}Requirements:
For environments where MSI is not supported (e.g., cross-tenant):
{
"type": "AzureDatabricks",
"typeProperties": {
"domain": "https://adb-<id>.azuredatabricks.net",
"accessToken": {
"type": "AzureKeyVaultSecret",
"store": { "referenceName": "LS_AzureKeyVault" },
"secretName": "databricks-access-token"
}
}
}Best for development and small-to-medium workloads with predictable scheduling.
{
"existingClusterId": "@{pipeline().parameters.p_cluster_id}"
}Pros: Fast startup, cost-effective for frequent short runs
Cons: Shared resource contention, single point of failure
Spins up a dedicated cluster per pipeline run — full isolation.
{
"newClusterVersion": "14.3.x-scala2.12",
"newClusterNumOfWorker": "2",
"newClusterSparkConf": {
"spark.databricks.delta.preview.enabled": "true",
"spark.sql.adaptive.enabled": "true"
},
"newClusterNodeType": "Standard_DS3_v2",
"newClusterInitScripts": [],
"newClusterDriverNodeType": "Standard_DS3_v2"
}Sizing guidance:
| Workload Type | Worker Count | Node Type | Rationale |
|---|---|---|---|
| Light ETL (< 10 GB) | 1-2 | Standard_DS3_v2 | Cost-efficient, sufficient memory |
| Medium ETL (10-100 GB) | 2-4 | Standard_DS4_v2 | Balanced compute and memory |
| Heavy ETL (100+ GB) | 4-8 | Standard_E8s_v3 | Memory-optimized for large joins |
| ML Training | 2-8 | Standard_NC6s_v3 | GPU-enabled for model training |
Pre-provision VMs to reduce cluster startup time from ~5 minutes to ~30 seconds.
Configure instance pools in Databricks, then reference via:
{
"newClusterVersion": "14.3.x-scala2.12",
"instancePoolId": "<pool-id>",
"newClusterNumOfWorker": "2"
}Pass scalar values directly from ADF to notebooks:
{
"type": "DatabricksNotebook",
"typeProperties": {
"notebookPath": "/Shared/pipelines/bronze/ingest",
"baseParameters": {
"source_path": "@pipeline().parameters.p_source_path",
"load_date": "@formatDateTime(utcNow(), 'yyyy-MM-dd')",
"pipeline_run_id": "@pipeline().RunId"
}
}
}In the Databricks notebook:
source_path = dbutils.widgets.get("source_path")
load_date = dbutils.widgets.get("load_date")
pipeline_run_id = dbutils.widgets.get("pipeline_run_id")ADF base parameters only support strings. For complex objects, serialize to JSON:
{
"config_json": "@string(json(concat('{\"tables\": [\"orders\", \"customers\"], \"mode\": \"incremental\"}')))"
}In the notebook:
import json
config = json.loads(dbutils.widgets.get("config_json"))
tables = config["tables"]Use dbutils.notebook.exit() to return values to ADF:
import json
result = {"status": "SUCCESS", "rows_processed": row_count, "output_path": output_path}
dbutils.notebook.exit(json.dumps(result))Access in subsequent ADF activities:
@activity('RunBronzeNotebook').output.runOutput.status
@activity('RunBronzeNotebook').output.runOutput.rows_processed
The included databricks_notebook_activity.json pipeline implements this pattern:
[Bronze Notebook] → [Check Output] → [Silver Notebook] → [Check Output] → [Gold Notebook]
Each stage validates the output of the previous stage before proceeding. This prevents cascading failures where corrupted bronze data propagates through silver and gold layers.
For independent tables that can be processed simultaneously:
┌─ [Bronze: Orders] ─┐
[Get Table List] ─→ ├─ [Bronze: Customers] ├─→ [Silver: Join & Transform]
└─ [Bronze: Products] ─┘
Implementation using ForEach with isSequential: false:
{
"type": "ForEach",
"typeProperties": {
"items": "@pipeline().parameters.p_table_list",
"isSequential": false,
"batchCount": 5,
"activities": [
{
"type": "DatabricksNotebook",
"typeProperties": {
"notebookPath": "/Shared/pipelines/bronze/generic_ingest",
"baseParameters": {
"table_name": "@item().table_name",
"source_schema": "@item().source_schema"
}
}
}
]
}
}For complex dependency graphs, use nested pipelines:
[Master Pipeline]
├─ Execute Pipeline: bronze_ingestion (parallel ForEach)
├─ Execute Pipeline: silver_transforms (sequential, waits for bronze)
└─ Execute Pipeline: gold_aggregations (sequential, waits for silver)
Configure retries at the activity level:
{
"policy": {
"timeout": "0.02:00:00",
"retry": 2,
"retryIntervalInSeconds": 120
}
}Recommended settings by scenario:
| Scenario | Retry Count | Interval | Timeout |
|---|---|---|---|
| Cluster startup issues | 2 | 120s | 2h |
| Transient network errors | 3 | 60s | 1h |
| Long-running transforms | 1 | 300s | 4h |
Structure notebooks to return structured error information:
try:
# Processing logic
result = {"status": "SUCCESS", "rows_processed": count}
except Exception as e:
result = {"status": "FAILED", "error": str(e), "error_type": type(e).__name__}
finally:
dbutils.notebook.exit(json.dumps(result)){
"type": "IfCondition",
"typeProperties": {
"expression": {
"value": "@equals(activity('RunNotebook').output.runOutput.status, 'SUCCESS')",
"type": "Expression"
},
"ifTrueActivities": [...],
"ifFalseActivities": [...]
}
}newClusterSparkConf["spark.databricks.cluster.profile"] to "singleNode" for light workloadsADF provides built-in monitoring for Databricks activities:
Pass pipeline().RunId to every notebook as a parameter. Use this to correlate:
ADFPipelineRun
| where PipelineName contains "Databricks"
| where Status == "Failed"
| project TimeGenerated, PipelineName, RunId, ErrorMessage = Parameters
| order by TimeGenerated desc© 2026 Datanest Digital. All rights reserved.
Datanest Digital — datanest.dev
Azure Data Factory billing is based on four cost dimensions: pipeline orchestration (activity runs), data movement (DIU-hours), data flow execution (vCore-hours), and integration runtime (SHIR uptime). This guide provides actionable strategies to reduce costs across all dimensions.
| Dimension | Unit | Approximate Cost |
|---|---|---|
| Pipeline activity runs | Per 1,000 runs | $1.00 |
| Data movement (Azure IR) | Per DIU-hour | $0.25 |
| Data movement (SHIR) | Per hour | $0.10 |
| Data flow (General) | Per vCore-hour | $0.268 |
| Data flow (Compute Optimized) | Per vCore-hour | $0.212 |
| Data flow (Memory Optimized) | Per vCore-hour | $0.335 |
*Prices are approximate and region-dependent. Check Azure pricing calculator for current rates.*
Every activity execution incurs a charge. Consolidate where possible:
Instead of one Lookup per table in a ForEach loop:
BAD: ForEach → [Lookup Config] → [Copy Data] (2 activities × N tables)
GOOD: [Lookup All Configs] → ForEach → [Copy Data] (1 + N activities)
DIUs determine the compute power allocated to Copy activities. More DIUs = faster copies but higher cost.
| Data Volume | Recommended DIU | Notes |
|---|---|---|
| < 100 MB | 2 (minimum) | Default is fine |
| 100 MB - 1 GB | 4 | Slight improvement over 2 |
| 1 - 10 GB | 4-8 | Monitor throughput vs. cost |
| 10 - 100 GB | 8-16 | Diminishing returns above 16 |
| 100+ GB | 16-32 | Test; not always linear scaling |
ADF defaults to "Auto" (up to 256 DIUs). For cost control:
{
"dataIntegrationUnits": 4
}Set this explicitly in your Copy activity. Monitor the actual DIU usage in pipeline run output:
activity('CopyData').output.usedDataIntegrationUnits
If actual usage is consistently below your setting, reduce it.
The parallelCopies property controls concurrent read/write threads within a single Copy activity:
{
"parallelCopies": 4
}null (ADF auto-determines)SHIR is billed per node, per hour — whether active or idle.
| Concurrent Pipelines | SHIR Nodes | CPU Cores | RAM |
|---|---|---|---|
| 1-5 | 1 | 4 | 8 GB |
| 5-20 | 2 | 4 | 16 GB |
| 20-50 | 2-4 | 8 | 32 GB |
| 50+ | 4+ | 8+ | 32+ GB |
1. Auto-shutdown for non-production — Use Azure Automation to stop SHIR VMs outside business hours:
# Azure Automation runbook
$vms = Get-AzVM -ResourceGroupName "rg-shir" -Status
foreach ($vm in $vms) {
if ($vm.PowerState -eq "VM running") {
Stop-AzVM -Name $vm.Name -ResourceGroupName "rg-shir" -Force
}
}2. Shared SHIR — A single SHIR node can be shared across multiple ADF instances (up to 4). Use this for dev/test environments.
3. Right-size VMs — Start with Standard_D2s_v3 and scale up only if you see queue times in SHIR monitoring.
4. Use Azure IR where possible — If your source is publicly accessible or in Azure, prefer Azure IR over SHIR.
Limit concurrent pipeline runs to prevent resource contention and cost spikes:
{
"policy": {
"pipelineConcurrency": 5
}
}Default batchCount is 20 (max 50). Lower this to reduce peak resource consumption:
{
"type": "ForEach",
"typeProperties": {
"batchCount": 5,
"isSequential": false
}
}If multiple pipelines trigger at the same time (e.g., hourly on the hour), stagger them:
| Pipeline | Schedule |
|---|---|
| Ingest Orders | :00 past the hour |
| Ingest Customers | :05 past the hour |
| Ingest Products | :10 past the hour |
This flattens the SHIR load curve and avoids queueing.
Data flows are the most expensive ADF component. Optimize aggressively.
If your transformation is simple (column mapping, type conversion, filtering), a Copy activity with column mapping is 10-50x cheaper than a data flow.
Use Data Flows for:
Use Copy Activity for:
Data flow clusters take 3-5 minutes to start. TTL keeps the cluster warm:
{
"timeToLive": 10
}Start with General Purpose, 8 cores and scale based on actual execution metrics:
activity('DataFlowActivity').output.runStatus.metrics.sink1.rowsWritten
activity('DataFlowActivity').output.runStatus.profile.compute.coreCount
Set up Azure Monitor dashboards for:
PipelineSucceededRuns / PipelineFailedRuns — track failure-driven rerunsActivitySucceededRuns — total billable activity executionsIntegrationRuntimeCpuPercentage — SHIR utilization (low = over-provisioned)IntegrationRuntimeAvailableMemory — SHIR memory headroomUse Log Analytics to identify expensive pipelines:
ADFActivityRun
| where TimeGenerated > ago(30d)
| summarize RunCount = count(), TotalDurationMin = sum(Duration) / 60000 by PipelineName
| order by RunCount desc
| take 20Monthly Cost ≈
(Activity Runs / 1000 × $1.00)
+ (DIU-Hours × $0.25)
+ (SHIR Node Hours × $0.10)
+ (Data Flow vCore-Hours × $0.268)
parallelCopies to optimize throughput per DIU© 2026 Datanest Digital. All rights reserved.
Get the full Azure Data Factory Integration Templates and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.