Skip to content
Back to Blog
8 min read

Readable Fabric Pipelines: Names, Boundaries and Config Off the Canvas

Most Fabric pipelines don’t become hard to maintain because of one bad decision. They get there one activity at a time: a Lookup called Lookup3, an If Condition added for a single source, a connection string pasted into a Copy activity “just for now”. Six months later the person on call opens the canvas at 2am and can’t tell what the pipeline does, in what order, or what changes between dev and prod.

Readability is an operational property, not a style preference. If the next engineer can’t read the pipeline, they can’t fix it safely, and they will add another branch rather than touch the existing ones. This post covers the rules I use to stop that drift: names, boundaries, configuration and review.

What “readable” means for a pipeline

My test is three questions an engineer who didn’t build the pipeline should be able to answer in a few minutes, from the canvas and the Git repo alone:

  1. What does this run? Which sources, which destinations, which notebooks.
  2. In what order, and why? What each dependency arrow protects against.
  3. What changes between environments? And where that difference lives.

Everything below is in service of those three answers. If you’ve read my post on gating daily loads on data readiness, this is the companion piece: that one is about making dependencies correct, this one is about keeping them legible a year later.

Name things before the first commit

Activity names do more work than they look like. They are what you see in Monitor hub when a run fails, and they are how expressions refer to outputs: @activity('Lookup3').output.firstRow tells a reader nothing, while @activity('Get watermark for orders').output.firstRow reads like a sentence. Because downstream expressions reference activities by name, a lazy name gets copied into every expression that consumes its output, which makes it expensive to fix later.

Item names matter for a different reason. When a workspace is connected to Git, Fabric creates a directory for each item using the pattern {display name}.{type}, and the source code format documentation says that Git integration doesn’t rename that directory afterwards, even when you rename the item. Rename Pipeline 1 to pl_sales_daily_parent after the first commit and, at the time of writing, your repo keeps Pipeline 1.DataPipeline unless someone renames the folder by hand and deals with the dependencies. The one documented exception: if the item’s folder name ends with a ’.’ suffix, the folder is renamed to match on the next commit. That mismatch between canvas and repo is exactly the kind of drift this post is about, so I name items properly before the workspace is ever synced.

The convention itself matters less than having one. This is the one I default to:

ThingPatternExample
Parent pipelinepl_<domain>_<cadence>_parentpl_sales_daily_parent
Child pipelinepl_<domain>_<step>pl_sales_load_bronze
ActivityVerb plus object, in plain EnglishCopy orders to bronze
Pipeline parametercamelCase nounbatchDate, sourceSystem
Library variablePascalCase noun with scopeSalesLakehouseId, LandingContainer

Every activity also has a Description field in its General settings. I use it for the why, not the what. “Copies orders” is redundant with a good name. “Runs after the readiness check because the ERP export lands late on month-end” is the sentence that stops someone deleting the dependency.

Draw boundaries with child pipelines

A single pipeline in Fabric can hold up to 120 activities, and that count includes the activities inside ForEach, If Condition, Switch and Until containers, according to the Data Factory limitations page. You will lose readability long before you hit that limit. My rough line is that once a pipeline needs scrolling to see the whole canvas, or a container holds more than a handful of activities, it’s time to split.

The split I use is a parent that owns order and children that own a single job each, called with the Invoke pipeline activity. The newer version of that activity can call pipelines in other workspaces and shows child runs in Monitor hub. The legacy version only reaches pipelines in the same workspace and only shows you the parent run. For readability, child-run monitoring is the bigger difference: when a parent fails, you want to click straight into the child that failed, not reconstruct it from run IDs.

A child pipeline is only readable if its contract is. I treat each child like a function:

  • Inputs are parameters, declared with types and sensible defaults. A pipeline can have at most 50 parameters. If you need anywhere near that many, the child is doing too much.
  • Outputs are a pipeline return value, which you set with the Set Variable activity instead of a pipeline variable. Return the row count and the batch identifier, not a dump of every activity’s output.
  • Failure is explicit. The child fails when its job failed, so the parent’s dependency conditions mean what they say. I covered that pattern in catching errors without hiding failed runs.

The trade-off is indirection. Every child adds a hop when you trace a run, and the newer Invoke pipeline activity needs a connection, using an organisational account, service principal or workspace identity, that somebody owns and maintains. Don’t create a child for a two-activity step that nothing else reuses. Split where there is a real boundary: a different source system, a different owner, or a step you need to rerun on its own.

Keep configuration off the canvas

Hard-coded values are the fastest way to make a pipeline unreadable, because the reader can’t tell which literals are logic and which are environment. Fabric gives you three places to put values, and they’re for different jobs:

MechanismScopeUse it for
Pipeline parametersOne run, set by the callerWhat this run should process: batchDate, sourceSystem
Pipeline variablesInside one runIntermediate state: a resolved date, an accumulated list
Variable libraryWorkspace, with a value set per stageWhat differs by environment: lakehouse IDs, connection IDs, container names

Variable libraries became generally available at the end of September 2025, with pipelines among the supported consumers. You add a reference to a library variable in the pipeline’s Library variables tab, then use it in dynamic content as @pipeline().libraryVariables.SalesLakehouseId. Each stage of a deployment pipeline gets its own active value set, so the pipeline definition is identical in dev, test and prod and the differences are in one item you can read in a single screen.

The pipeline integration docs list the limits worth knowing before you commit to it. Number variables aren’t supported in pipelines, and Datetime and Guid values arrive as strings. To parameterise an external connection you store the connection’s GUID, which you look up under Manage connections and gateways.

Every library variable a pipeline uses has to be added as a reference in the Library variables tab at design time, so that tab is a complete list of the environment-specific values the pipeline depends on. I see that as a feature for readability: a reviewer can answer “what changes between environments?” from one tab instead of searching every expression.

Keep expressions short enough to read

An expression can be up to 8,192 characters. That limit exists for generated pipelines, not as a target. When an expression needs a horizontal scrollbar, split it into a named Set Variable step so each piece has a name in Monitor and in the run output.

This is the kind of expression that accumulates in a pipeline patched for a year:

@concat('Files/landing/', pipeline().parameters.sourceSystem, '/', formatDateTime(if(empty(pipeline().parameters.batchDate), utcNow(), pipeline().parameters.batchDate), 'yyyy/MM/dd'), '/')

Split it into a Set Variable activity named Resolve batch date, writing to a string pipeline variable resolvedBatchDate:

@formatDateTime(if(empty(pipeline().parameters.batchDate), utcNow(), pipeline().parameters.batchDate), 'yyyy-MM-dd')

Then the Copy activity’s folder path becomes something a reviewer can check at a glance:

@concat('Files/landing/', pipeline().parameters.sourceSystem, '/', replace(variables('resolvedBatchDate'), '-', '/'), '/')

The output of Resolve batch date also appears in the run details, so when a load picks up the wrong day, you can see the resolved value instead of re-evaluating the expression in your head. If the logic needs more than two or three nested functions, it probably belongs in a notebook or a stored procedure, where it can be tested.

Make readability reviewable

Rules that live in a wiki decay. Because a Git-connected workspace stores each pipeline’s definition as pipeline-content.json in its item directory, you can check the rules in a pull request. This script is complete as shown and runs with Python 3.9 or later against a local clone of the repo. It walks every pipeline definition, including activities nested inside containers, and flags default-style names, missing descriptions, deactivated activities and pipelines that are getting crowded:

"""Report readability problems in Fabric pipeline definitions synced to Git."""
import json
import re
import sys
from pathlib import Path

MAX_ACTIVITIES = 120  # Fabric limit per pipeline, including activities inside containers
WARN_AT = 40  # team-tunable threshold for "split this into child pipelines"
DEFAULT_NAME = re.compile(r"^[A-Za-z][A-Za-z ]*\d+$")  # e.g. "Copy data1", "Lookup3"
NESTED_KEYS = ("activities", "ifTrueActivities", "ifFalseActivities", "defaultActivities")


def walk(activities):
    for activity in activities:
        yield activity
        props = activity.get("typeProperties", {})
        for key in NESTED_KEYS:
            yield from walk(props.get(key, []))
        for case in props.get("cases", []):
            yield from walk(case.get("activities", []))


def check(path):
    definition = json.loads(path.read_text(encoding="utf-8"))
    activities = list(walk(definition.get("properties", {}).get("activities", [])))
    problems = []
    if len(activities) > WARN_AT:
        problems.append(
            f"{len(activities)} activities (hard limit {MAX_ACTIVITIES}); consider a child pipeline"
        )
    for activity in activities:
        name = activity.get("name", "")
        if DEFAULT_NAME.match(name):
            problems.append(f"'{name}' still has a default-style name")
        if not activity.get("description", "").strip():
            problems.append(f"'{name}' has no description")
        if activity.get("state") == "Inactive":
            problems.append(f"'{name}' is deactivated; delete it or explain why it stays")
    return problems


def main(root):
    found_problems = False
    for path in sorted(Path(root).rglob("pipeline-content.json")):
        for problem in check(path):
            print(f"{path.parent.name}: {problem}")
            found_problems = True
    return 1 if found_problems else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "."))

Run it in your pull request build and fail on a non-zero exit code. WARN_AT is set to 40, a third of the hard limit, as a deliberately loose backstop: the scrolling test above usually says “split” sooner, and the lint check catches the pipelines where nobody applied it. Treat it as a named constant the team is expected to tune, and lower it once your pipelines settle into a typical size. The name pattern is a heuristic and will occasionally flag a legitimate name like Load 2025, and the description rule is strict on purpose. Relax either one once the team has the habit.

The deactivated-activity check deserves a word. Deactivating an activity is a useful way to comment out a step while debugging. Committed and left for months, inactive activities become ghosts on the canvas: nobody knows whether they’re safe to delete, so nobody deletes them. Inside Fabric, the Compare code changes view in Git integration (in public preview at the time of writing) shows a file-level diff before you commit, which is a good moment to catch them.

When this is more than you need

Not every pipeline deserves this treatment. A pipeline with five activities, one owner and one environment is readable already. Adding a parent, a variable library and a lint step to it is ceremony. The same goes for a one-off backfill you’ll delete next week.

The rules pay off when at least one of these is true: more than one person maintains the pipeline, it runs in more than one environment, or someone other than the author gets paged when it fails. That describes most pipelines that survive their first quarter in production, which is why I’d rather start with names and descriptions on day one than retrofit them later.

The rule I’d keep if I could only keep one

Name every activity and item for the person who reads it at 2am, and do it before the first Git commit. Boundaries, variable libraries and lint checks all matter, but they rely on names. A pipeline with honest names and a one-line why on each dependency can be read, fixed and split later. A pipeline full of Lookup3 and If Condition1 gets another branch bolted on, and then another.

Michael John Peña

Michael John Peña

Data & AI Director and Microsoft MVP based in Sydney. Writing about Microsoft Fabric, Azure, data engineering, and AI.