← Back to Field Notes

Delta Deployments: What Works in 2026

·9 min read

The first delta deployment I shipped deployed nothing, and the pipeline went green while it did it.

This was at an APRA-regulated lender in Sydney. Our validation deploys had crept up to about 47 minutes because the pipeline pushed the entire force-app directory on every pull request — a few thousand components and the full local test suite, every time someone corrected a typo in a help text. I moved us to delta deployments to kill the wait. Then I spent the better part of two days working out why a bug fix that showed as deployed had never actually reached the sandbox. The job was cloning the repo at depth 1, sfdx-git-delta couldn't see the commit history, and it produced an empty package that deployed cleanly and changed absolutely nothing.

That is delta deployments in one story: enormously faster, and quietly dangerous if you don't understand what they are comparing.

What A Delta Deployment Actually Is

A delta deployment is a deployment of only the metadata that changed between two git references — usually your feature branch and the branch you're merging into. Instead of handing Salesforce the whole force-app tree and waiting for it to reconcile thousands of components, you hand it a package.xml containing the six or twelve things you actually touched.

The mechanics are pure git. You diff two commits, translate the changed file paths into metadata members, and generate a manifest. Deleted files become a destructiveChanges.xml. Everything else becomes a package.xml. Salesforce never knows the rest of your repository exists on that particular run.

This is the single biggest lever on pipeline speed for any org of real size. I've covered where it sits in the wider pipeline in CI/CD on Salesforce; this piece is the part underneath that link — how the delta itself is generated, and the specific ways it bites.

sfdx-git-delta Is The Tool I Reach For

I've tried computing deltas by hand with git diff --name-only and a pile of shell that maps file paths to metadata types. Don't. The path-to-metadata mapping is deceptively fiddly — bundled types like LWC and Aura, the -meta.xml sidecars, decomposed objects where a field lives in its own file. sfdx-git-delta (the sgd plugin) already knows all of it.

Install it and generate the delta from two refs. The command looks like this:

sf plugins install sfdx-git-delta

sf sgd source delta \
  --from "origin/main" \
  --to "HEAD" \
  --output-dir delta \
  --generate-delta \
  --source-dir force-app

That writes two things worth knowing about: delta/package/package.xml (what changed or was added) and delta/destructiveChanges/destructiveChanges.xml (what was deleted). The --generate-delta flag also copies the changed source files into the delta folder, so you can deploy straight from there rather than from the full tree.

The --from reference is the part people get wrong. It has to be the merge target as it exists in the org you're deploying to — not HEAD~1, not whatever the last commit happened to be. If you compare against the wrong base, you either deploy things that are already live or, worse, miss things that aren't. On a PR pipeline I always compare against the actual target branch, fetched fresh.

Wiring It Into A Pipeline

Here's the GitHub Actions job I use for PR validation. The comment on fetch-depth is not decoration — it is the exact bug from the opening of this article.

name: Validate Delta
on:
  pull_request:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # sgd needs full history; depth 1 = silent empty delta

      - name: Install CLI and plugin
        run: |
          npm install --global @salesforce/cli
          echo y | sf plugins install sfdx-git-delta

      - name: Authenticate
        run: sf org login sfdx-url --sfdx-url-file <(echo "$SFDX_AUTH_URL")
        env:
          SFDX_AUTH_URL: ${{ secrets.SFDX_AUTH_URL }}

      - name: Generate delta
        run: |
          sf sgd source delta \
            --from "origin/${{ github.base_ref }}" \
            --to "HEAD" \
            --output-dir delta \
            --generate-delta \
            --source-dir force-app

      - name: Validate (check-only)
        run: |
          sf project deploy validate \
            --manifest delta/package/package.xml \
            --post-destructive-changes delta/destructiveChanges/destructiveChanges.xml \
            --test-level RunLocalTests \
            --wait 60

Two details earn their place. fetch-depth: 0 gives sgd the history it needs; the default shallow clone is the classic way to ship a green pipeline that does nothing. And this is deploy validate, not deploy start — on a PR I only ever check-only. The validated deployment ID from this run is what I later quick-deploy to production, so the tests never run twice.

Test Level Is Where The Time Actually Goes

Trimming the metadata is only half the win. On most orgs, the deployment clock is dominated by Apex tests, not by pushing components. A delta of eight files still runs your entire local test suite if you tell it to, and for us that suite was roughly 1,900 tests.

The obvious optimisation is RunSpecifiedTests — run only the tests for the classes in the delta. The obvious way to build that list is also a trap. You cannot simply pull the test classes out of the diff:

# The NAIVE version — this is wrong, and I'll explain why below
TESTS=$(grep -oE '<members>[A-Za-z0-9_]+</members>' delta/package/package.xml \
  | grep -oE '[A-Za-z0-9_]+' \
  | grep -E 'Test$' \
  | sort -u | paste -sd, -)

The problem is direction. When you change PaymentController, its test class PaymentControllerTest is usually not in the diff — you didn't touch the test, you touched the code. So a specified-tests list derived purely from the changed files runs the tests you edited and skips the tests that actually cover your change. That is how untested code sails through a green validation.

My honest position: unless you maintain a real dependency map from production classes to their covering tests, use RunLocalTests and eat the minutes. If you do want specified tests, derive them from a naming convention you enforce — every Foo has a FooTest — and expand the changed classes into their test counterparts, not the other way around. The convention only holds if it's non-negotiable, which is the same discipline I lean on for the suites in Apex test patterns that catch real bugs. Get the mapping wrong and delta hands you speed in exchange for coverage you think you have and don't.

Destructive Changes Are The Part That Bites

Adding metadata through a delta is forgiving. Deleting it is where I've been burned, and where I now insist on a manual gate.

sgd generates destructiveChanges.xml from files that disappeared between the two refs, and the deploy applies them either before or after the additive package depending on which flag you use. That ordering matters. Deleting a field that a newly added validation rule references, in the wrong order, fails the whole deployment. Renaming — which git sees as a delete plus an add — is the usual culprit.

The deeper problem is that the delta only sees your repository, and Salesforce sees the whole org. We once deleted a field that the diff cleanly captured, the destructive change validated, and it still failed in production because a dashboard nobody had touched in years referenced the field. The delta had no way to know. We patched it, added a "check for external references" step to the field-deletion runbook, and that manual step is still there three years later. I never automated it away, because I've never fully trusted a machine to know what a report built in 2019 depends on.

Profiles And Permission Sets Deploy As Whole Files

This one deserves its own warning. Profiles and permission sets don't decompose neatly, and sgd treats a changed profile as a single changed file. If one user permission changed, the delta carries the entire profile — every object permission, field permission, and tab setting in it.

That cuts both ways. Sometimes it over-deploys, dragging along field permissions for objects that aren't in this delta at all, and the deployment fails because those referenced components don't exist in the target. Sometimes, if your source-tracking has drifted, it silently reasserts stale access. Either way, permissions are the metadata I least want a diff making decisions about on my behalf. It's one more reason I keep moving orgs off fat profiles and onto composable permission sets, which I've written about in moving from profiles to permission sets — smaller, single-purpose files behave far better in a delta than a 4,000-line profile that changes for a dozen unrelated reasons.

When Delta Isn't The Answer

Delta is a PR-validation optimisation, not a deployment philosophy. There are runs where I deliberately deploy the full source.

The first is the merge to your main branch. Two PRs can each pass their own delta validation and still conflict when combined — a class one added, a field the other removed. The per-PR delta never sees the collision. So staging always gets a full deploy of the complete branch state; that's the integration check the deltas can't give you.

The second is any large refactor or an initial org build-out, where "what changed" is "nearly everything" and the delta is just a slower path to a full deploy. The third is disaster recovery, where I want the org to match the repository exactly and I don't want a diff quietly deciding what to leave alone.

And the honest fourth: I don't hand destructive deltas to production without a person reading them. The time delta saves on the additive path, I happily give back on the deletion path. If you're standing up a delta pipeline and want a second pair of eyes on the destructive-change path before it touches production, that's the kind of review I do.

The full deploy told me nothing was broken by making me wait 47 minutes to hear it. The delta deploy tells me in four — as long as I remember it's only ever as clever as the diff I hand it.

Liked this?

New essays in your inbox when I publish. No cadence, no spam.