Choosing between Salesforce CLI deploy start and validate

sf project deploy validate vs start: which to run and when

Reading Time: 23 min
Author: William Watson Published: February 16, 2026 Updated: October 15, 2026 System: Salesforce CLI deployments through CI/CD to sandboxes and production Scale: Multi-environment release pipelines with long-running Apex test suites Failure: Expired or rejected quick deploys, test-level mistakes and timed-out deployments Audience: Senior Salesforce developers and release engineers
Current: Top of article
Direct answer

Validate with tests in the release pipeline, promote the validated job ID with sf project deploy quick inside its 10-day window, and keep sf project deploy start for environments where you mean to change state now.

If your team uses one deploy command for every environment, you are either too slow or too risky. sf project deploy start, sf project deploy validate and sf project deploy quick solve different problems, and the wrong choice shows up as a release that overruns its window, a quick deploy refused on release night, or a test level that passes in a sandbox and fails in production. For the flags, dry runs, test levels and error messages of start itself, see the post on sf project deploy start. This one is about which command to run.

This article expands the deploy strategy in the Salesforce CLI DevOps Playbook. Every command below was written against @salesforce/cli 2.152.14, the latest tag on npm on 6 October 2026, and checked against that version’s command reference rather than run in an org. Check yours with sf --version, because flag lists and defaults change between releases.

Why does this fail in production?

The same command behaves differently in a sandbox and in production because the defaults differ, and a validation is a dated artefact with conditions attached.

Three facts from the Salesforce CLI command reference drive most release incidents:

  • NoTestRun applies only to development environments such as sandboxes, Developer Edition and trial orgs, and it is the default there. RunLocalTests is the default for production deployments that include Apex classes or triggers. A sandbox pipeline that never sets a test level has never run your tests.
  • A validation’s job ID expires, and the validated deployment must itself have succeeded for the quick deploy to succeed. The constraints under quick deploy below give the limits.
  • --wait defaults to 33 minutes on start, validate and quick. When the CLI stops watching, the deployment carries on in the org without you.

What is the difference between deploy start and deploy validate?

deploy start performs a real deployment, while deploy validate checks whether the deployment would succeed and returns a job ID you can promote later without re-running tests.

Under the hood, validate is a start run as a dry run with tests made mandatory: the command’s source passes dry-run: true to the shared deploy routine and only accepts test levels that execute tests. The reference describes it as similar to deploy start, except that you are required to run Apex tests and the command returns a job ID rather than executing the deployment.

Quick comparison:

  • sf project deploy start: deploys metadata now.
  • sf project deploy validate: runs the deployment with tests but saves nothing and returns the job ID that quick promotes.
  • sf project deploy quick: deploys a successful validation by job ID without re-running tests.

When should I use sf project deploy validate?

Use validate for production preflight: run it when the release candidate is final, keep the job ID, and promote with quick inside the release window.

# @salesforce/cli 2.152.14
sf project deploy validate \
  --source-dir force-app \
  --target-org "$SF_PROD_ALIAS" \
  --test-level RunLocalTests \
  --wait 90 \
  --json > validate.json

The reference is explicit about where this belongs: do not use the command on sandboxes, because it is intended for production orgs, and by default sandboxes do not run tests during a deploy. For a sandbox check with tests it points you to sf project deploy start --dry-run --test-level RunLocalTests instead. It also notes that validate does not support source tracking, so the source you later quick deploy overwrites the matching metadata in the org.

When should I use sf project deploy start?

Use start when you intend to change the org now: scratch orgs, developer and integration sandboxes, and approved release stages. In production it fits only when the whole test run sits inside the window and you accept that a failure rolls back at the end of it.

# @salesforce/cli 2.152.14
sf project deploy start \
  --source-dir force-app \
  --target-org "$SF_SANDBOX_ALIAS" \
  --test-level RunLocalTests \
  --wait 60

If the production test suite takes longer than your window, you want validate and quick, not a bigger --wait.

How does quick deploy fit into this workflow?

Quick deploy promotes a successful validation without re-running tests, which moves the slow part of a production release to a day of your choosing.

# @salesforce/cli 2.152.14
sf project deploy quick \
  --job-id "$SF_VALIDATE_JOB_ID" \
  --target-org "$SF_PROD_ALIAS" \
  --wait 60

The constraints, each from the CLI reference for project deploy quick and project deploy validate:

  • The job ID is valid for 10 days from when you started the validation.
  • The validated deployment must have succeeded; a failed validation cannot be quick deployed.
  • --use-most-recent only looks at job IDs validated in the past 3 days or less. Older than that and the flag finds nothing, even though the job ID itself is still valid.
  • validate and quick are intended for production orgs, not sandboxes, because sandboxes do not run tests by default. The platform operation behind quick is deployRecentValidation(), which the command’s source calls. Quick deployment itself is not limited to production. The DevOps Center help describes quick deploying a validated bundle to a pipeline stage as well as to the release environment, and it is clear that a validation run without tests cannot be quick deployed.

On what invalidates a validation before the job ID expires: Salesforce’s Trailhead unit on the org development model says the quick deploy window holds as long as you do not perform another deployment or make major changes to the org. The DevOps Center help says the same in its own terms: any subsequent promotion disqualifies an existing validation. The CLI reference does not spell out the rule. In my projects, anything else deployed to the same org between validation and quick deploy has meant validating again, so treat the job ID as single use and re-validate if the org has moved.

Best Practice: Validate early on release day rather than a week ahead. You stay inside the reach of --use-most-recent, and fewer unrelated changes can land in between.

Which test level should I pair with these commands?

Match the test level to the org’s risk, and know which coverage rule applies, because the per-class rule and the overall rule are different and the CLI reference describes both.

From the --test-level description in the CLI reference:

  • NoTestRun runs nothing and applies only to development environments, where it is the default.
  • RunSpecifiedTests runs only the tests you name with --tests. Those tests must give each class and trigger in the deployment package at least 75% coverage individually, which the reference notes is different from the overall coverage percentage.
  • RunLocalTests runs every test in the org except those from installed managed and unlocked packages, and it is the default for production deployments that include Apex classes or triggers.
  • RunAllTestsInOrg runs everything, including managed package tests.
  • RunRelevantTests (Beta) lets Salesforce pick the tests relevant to the payload, with the same per-class 75% rule as RunSpecifiedTests.

The default production rule, from the Metadata API Developer Guide, is a minimum overall 75% across all classes and triggers, and no trigger at 0% coverage. Salesforce Help adds that the coverage restriction is not enforced for sandbox or Developer Edition orgs. So the common belief that every deployed class must hit 75% on its own under RunLocalTests is wrong: that is the RunSpecifiedTests rule.

Practical defaults:

  • Scratch orgs and developer sandboxes: NoTestRun for speed, with start --dry-run --test-level RunLocalTests before merging.
  • Hotfixes to production: RunSpecifiedTests naming the tests for the changed classes, accepting the per-class 75% bar.
  • Release candidates and production: RunLocalTests.
  • RunAllTestsInOrg: only when you need managed package tests in the gate. In my experience that is rare, and it hands your release window to code you do not own.

Which command and test level fit each environment and objective?

Pick the row that matches what you are trying to prove, not the org you happen to be pointed at.

Objective Environment Test level Command
Pull request check Scratch org or developer sandbox NoTestRun, or RunSpecifiedTests for the changed classes sf project deploy start --dry-run
Pull request check with full signal Integration sandbox RunLocalTests sf project deploy start --dry-run --test-level RunLocalTests
Release candidate UAT or staging sandbox RunLocalTests sf project deploy start --test-level RunLocalTests
Production promotion Production RunLocalTests sf project deploy validate, then sf project deploy quick --job-id
Hotfix Production RunSpecifiedTests with --tests, or RunLocalTests if the suite is short sf project deploy validate --test-level RunSpecifiedTests --tests ..., then quick; start when every minute counts

What are the trade-offs between validate-then-quick and start?

Validate-then-quick buys a short, predictable production window at the price of a perishable artefact and a two-step pipeline. start with tests is simpler and slower, and it fails late: the release window carries the whole test run, and a failure rolls back after you have waited for it. The table above pairs each path with the environment it fits.

What is the difference between deploy validate and deploy start –dry-run?

Both check whether a deployment would succeed, but validate is the documented route to a quick-deployable job ID, and --dry-run is the documented route to a sandbox check.

The reference describes --dry-run as “validate deploy and run Apex tests but don’t save to the org”, and because start accepts NoTestRun, a dry run can skip tests entirely. A CLI maintainer answered the quick deploy question on the issue tracker: a --dry-run can produce a valid quick deploy ID, but is not guaranteed to, and the recommended route is validate then quick. The June 2023 fix in plugin-deploy-retrieve pull request #651 allowed metadata-format dry runs to be quick deployed when valid. Do not build a release pipeline on it.

# @salesforce/cli 2.152.14
# Lighter check: no quick deploy, but NoTestRun is allowed
sf project deploy start \
  --source-dir force-app \
  --target-org "$SF_SANDBOX_ALIAS" \
  --test-level NoTestRun \
  --dry-run \
  --wait 30

What are the failure modes of validate and quick deploy?

Most release-night failures fall into five patterns, and each has a message you can search for, a cause, and a fix.

The validation has expired or been invalidated

Message, from the quick command’s error text:

Job ID can't be used for quick deployment. Possible reasons include the deployment hasn't been validated, has already been deployed, or the validation expired because you ran it more than 10 days ago.

Cause: the job ID has expired, the validation failed, it was already quick deployed, or another deployment to the org since the validation invalidated it.

Fix: run validate again against the current org and promote the new job ID. If the pipeline relied on --use-most-recent, remember that its reach is shorter than the life of the job ID; the constraints above have both numbers.

The test run outlasts --wait

Message, as sf project deploy start prints it:

The command has timed out, although the deployment is still running. Use "sf project deploy resume" to resume watching the deployment.

validate and quick do not map the timeout, so the message is the deploy library’s own. From the source rather than a run:

Metadata API request failed: The client has timed out.

Cause: --wait defaults to 33 minutes, and a RunLocalTests run on a large org can take longer. The deployment continues in the org regardless.

Fix: watch it again with sf project deploy resume --job-id <id>, or --use-most-recent if it started in the last 3 days. The reference notes that resume does not restart the operation, because the operation always continues once started. To poll without taking over the terminal, use sf project deploy report --job-id <id> --wait <minutes>, which checks every second until the timeout. Set --wait from measured test duration, and use --async with report when the pipeline runner has its own timeout.

Quick deploy exits with undefinedComponentSet

Message, as reported in the Salesforce CLI issue tracker:

Error (1): undefinedComponentSet

Cause: a CLI defect in sf project deploy quick, thrown from ComponentSetBuilder.build while the deployment itself completed successfully in the org. It was reported in April 2023 on sfdx-cli 7.197.8 with plugin-deploy-retrieve 1.8.12 (#2098), then on @salesforce/cli 1.79.5 with plugin-deploy-retrieve 1.9.1 (#2223); #2159 names no version. The maintainers fixed it in June 2023 in plugin-deploy-retrieve pull request #651, which made the component set optional for quick deploy, and #2223’s reporter confirmed the fix. A comment on #2098 from July 2024 reports the same error again on a newer CLI.

Fix: before you retry anything, run sf project deploy report --job-id <id>. If the deployment succeeded, do not deploy again. Pin the CLI version in CI, upgrade deliberately, and if you see it on a current CLI, add your version to #2098 on the forcedotcom/cli tracker.

validate refuses NoTestRun

Message, in the shape the CLI’s flag parser produces:

Expected --test-level=NoTestRun to be one of: RunAllTestsInOrg, RunLocalTests, RunSpecifiedTests, RunRelevantTests

Cause: validate defines only test levels that run tests, because quick deploy depends on the results.

Fix: use start --dry-run --test-level NoTestRun for a test-free check, and accept that it does not feed quick deploy.

The validation fails with a generic message

Messages, from the error text of the deploy plugin and of @salesforce/core:

Failed to validate the deployment (<job id>). Due To: <reason>
You must specify tests using the --tests flag if the --test-level flag is set to RunSpecifiedTests.
<directory> does not contain a valid Salesforce DX project.
Invalid deploy ID: <job id> for org: <org>

In order: FailedValidationError summarises a real metadata or test failure, so rerun with --verbose or read sf project deploy report --job-id <id>. RunSpecifiedTests without --tests is a flag mistake. InvalidProjectWorkspaceError means the command is not running inside a project. @salesforce/core looks for sfdx-project.json in the current directory and each directory above it, so any directory at or below the project root works. An invalid deploy ID usually means the wrong org: the report reference notes that on a different machine from the one that deployed, you must pass --target-org pointing at the same org. Both 15- and 18-character job IDs are accepted by --job-id.

What is the recovery playbook when the quick deploy path fails?

Work the scenario you are in from the top. Each step is ordered so that you learn the org’s true state before you change it.

Validation expired or invalidated

  1. Confirm with sf project deploy report --job-id <id>: status, whether it was a validation, and when it started.
  2. Check what has reached the org since, so you know whether to re-test the merge rather than just re-validate.
  3. Run validate again with the same test level and capture the new job ID as a pipeline artefact.
  4. Promote with quick while the window is open, then verify with report.

Job ID lost

  1. Try sf project deploy report --use-most-recent on the machine that ran the validation; it reaches back 3 days.
  2. If that fails, look in the pipeline logs for the validation’s output. In my pipelines the ID is read from the --json output and stored as a build artefact for exactly this reason.
  3. If the ID is gone and the validation has expired anyway, re-validate.

Tests pass in the sandbox and fail in production

  1. Check what the sandbox actually ran. Sandboxes do not run tests by default, so “passed” may mean “not run”.
  2. Compare test levels: RunLocalTests in production runs every local test in the org, including ones your change never touched.
  3. Pull the production failures with sf project deploy report --job-id <id> --junit --results-dir <dir>.
  4. Reproduce in a recently refreshed sandbox with start --dry-run --test-level RunLocalTests, fix or quarantine the failing tests, and validate again.

Deployment stuck past –wait

  1. Do not start a second deployment of the same payload: you will not know which one changed the org.
  2. sf project deploy resume --job-id <id> to watch it; report --wait if you only need the status.
  3. If the resumed deployment fails, read the component and test failures from report, fix, and go back to validate.
  4. Set the pipeline’s --wait from the measured run, and move the long test run into validate so it never sits inside the release window again.

What should I check first when a deployment misbehaves?

The checklist is the playbook’s: what to check first when a Salesforce CLI pipeline fails. It covers the CLI version, the authenticated user and instance, the org, the test level, the job ID, the org’s view of the result from report, later deployments to the org, and the runner’s timeout against --wait.

If your pipeline cannot authenticate reliably, fix that first with the headless JWT login for Salesforce CLI in GitHub Actions. The wider release flow, including how these stages fit around approvals, is in the Salesforce CLI DevOps Playbook.

What pipeline pattern works best for most teams?

Validate on the release candidate, store the job ID, and quick deploy after approval inside the same few days. The long test run stays out of the release window, and failures surface while there is still time to fix them.

# @salesforce/cli 2.152.14
# Stage 1: release candidate pipeline
sf project deploy validate --source-dir force-app --target-org "$SF_PROD_ALIAS" --test-level RunLocalTests --wait 90 --json > validate.json

# Stage 2: release pipeline, after approval, within 10 days of stage 1
sf project deploy quick --job-id "$SF_VALIDATE_JOB_ID" --target-org "$SF_PROD_ALIAS" --wait 60

# Stage 3: confirm the org's view of the result
sf project deploy report --job-id "$SF_VALIDATE_JOB_ID" --target-org "$SF_PROD_ALIAS"

Pass the job ID between stages as a pipeline artefact, and pass --target-org explicitly in the later stages, because they usually run on a different machine from the validation.

Top Tip: Treat the validation job ID like a release ticket: it has an owner, a start time and an expiry. Print all three in the pipeline summary so nobody has to guess on release night.

References

Need a Salesforce release pipeline you can trust?

I take short specialist contracts to harden Salesforce CI/CD, cut release risk and stop repeat deployment failures.

Discuss your deployment pipeline