Migration and practice
only and except were the standard way to run jobs conditionally in GitLab CI for years, but the rules keyword has superseded both for a long time and enables condition logic that only/except simply cannot express. This article shows why the migration pays off and how it succeeds step by step in existing pipelines.
Table of Contents
- 1. Why only/except reaches its limits
- 2. How rules makes conditions combinable
- 3. when, manual and the new default logic
- 4. Migration strategy for existing pipelines
- 5. Avoiding duplicate pipelines
- 6. Common mistakes during the switch
- 7. What teams typically gain after migrating
- 8. Tooling and validation during the switch
- 9. Conclusion: rules as the standard, only/except as legacy
- 10. Summary
- 11. FAQ
1. Why only/except reaches its limits
only and except emerged in an early phase of GitLab CI, when pipelines mostly had to answer simple questions: does the job run only on the main branch, only on tags, or should it be suppressed on merge requests. For such simple cases the two keywords worked fine for a long time, because they accepted a short list of refs, branches or variable conditions and GitLab derived a yes/no decision from that internally. The problem only shows up once several conditions need to apply at the same time, for example only on changes to a specific directory and only on protected branches and not on scheduled pipelines. only/except has no clean way to combine conditions with AND/OR, so teams used to work around this with duplicated jobs, cryptic variable names or nested bash conditions inside the script block, just to express a condition that really belongs in the pipeline configuration.
Another structural problem is that only/except switches between two implicit default behaviors depending on whether the keyword is set at all, which regularly causes surprises in grown pipelines: a job without only/except runs by default on branches and tags, but not automatically on merge request events, whereas a job with only: [merge_requests] suddenly assumes a completely different default behavior for refs. Anyone taking over a pipeline that has grown over years often spends more time figuring out why a job runs unexpectedly, or does not run at all, than on the actual pipeline logic. GitLab itself has officially recommended rules as the successor since version 12, and only/except is now considered a legacy feature that still works but no longer receives new capabilities.
2. How rules makes conditions combinable
rules defines an ordered list of rules that GitLab evaluates from top to bottom, where each rule consists of an optional if condition, optional changes or exists clauses, and an outcome that runs the job, skips it, or marks it as manual. If the first matching rule in the list applies, its outcome is used and the remaining rules are never checked, which makes rules behave like a switch statement for pipeline conditions rather than a simple filter list. The if field uses the same CI/CD variable expression language as workflow:rules and allows comparisons, regex matches and logical combinations with && and ||, so that something like CI_COMMIT_BRANCH == "main" && CI_PIPELINE_SOURCE == "push" expresses in a single line exactly the condition that used to require two separate only blocks.
The real strength shows up when if is combined with changes or exists: changes checks whether files in specific paths have changed since the last successful pipeline run, and exists checks whether certain files exist in the repository at all, for example to run a Docker build job only when a Dockerfile is actually present. Both clauses can be combined with if per rule, so a job might run only when the branch is main AND files under src/ have changed. This combinability is the central advantage over only/except, because it enables selective, resource-friendly pipelines that only run the jobs actually affected by a given change, instead of running the entire pipeline for every commit.
deploy_job:
stage: deploy
script:
- ./deploy.sh production
rules:
- if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
changes:
- src/**/*
- deploy/**/*
- if: '$CI_COMMIT_TAG'
when: on_success
- when: never
3. when, manual and the new default logic
An often overlooked difference between only/except and rules concerns the when behavior: with only/except, when is normally on_success regardless of which only/except condition matches, while rules lets each individual rule specify its own when, such as on_success, on_failure, always, manual or never. This allows fine-grained control within a single job without having to duplicate the job for it. A classic example is a deployment job that runs automatically on main, can only be triggered manually on other protected branches, and is skipped entirely on all other branches, all within one rules list with three entries instead of three separate jobs with different only/except blocks.
The implicit fallback rule matters: if none of the defined rules match, the job is skipped by default, unless a catch-all rule with when: never or an empty condition is explicitly added. This is fundamentally different from only/except, where a job without an explicit restriction generally runs. Anyone migrating an existing pipeline must therefore consciously decide, at the end of every rules list, what should happen for all uncovered cases instead of relying on an implicit behavior as before. In practice it is advisable to always close every rules list with an explicit final rule, so that nobody has to guess later why a job does not appear in a given context.
release:
stage: deploy
script:
- ./release.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success
- if: '$CI_COMMIT_BRANCH =~ /^release\//'
when: manual
- when: never
4. Migration strategy for existing pipelines
Migrating in one large commit is risky, because job behavior can change unintentionally and errors often only surface when a deployment fails to run or unexpectedly triggers on main. The safe path is a job-by-job migration: first document what only/except behavior a given job actually has, including implicit defaults, then write the equivalent rules logic alongside it and test it in a merge request pipeline before removing the old only/except block. It is especially helpful to add a temporary debug job that only echoes $CI_PIPELINE_SOURCE, $CI_COMMIT_BRANCH and similar variables, to verify that the new rules condition matches exactly the expected cases.
A common migration mistake is blindly replacing only: [merge_requests] with if: '$CI_PIPELINE_SOURCE == "merge_request_event"' without noting that only: [merge_requests] also implicitly disabled all other refs, which has to be rebuilt explicitly with rules. It also makes sense to combine the migration with simplification rather than a pure one-to-one translation: many pipelines contain historically grown job duplicates that only exist because of the limited only/except logic, and these can be merged into a single job with multiple combinable rules, which noticeably improves maintainability and shortens the pipeline file.
# Before: separate jobs because of the only/except limitation
# test_mr:
# only: [merge_requests]
# test_branch:
# only: [branches]
# except: [main]
# After: one job, two combined rules
# test:
# rules:
# - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# - if: '$CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != "main"'
5. Avoiding duplicate pipelines
A well known side effect when switching to merge-request-based workflows is that both a branch pipeline and a merge request pipeline can run for the same commit when rules is not configured cleanly. GitLab runs a pipeline for every push by default, plus another one as soon as a merge request exists for that branch, which unnecessarily doubles CI minutes and clutters the merge request view with two parallel pipeline statuses. The usual fix is a top-level workflow:rules configuration that suppresses branch pipelines whenever an open merge request pipeline already exists for the same branch, combined with the same if conditions used in the individual jobs.
In practice a consistent pattern proves useful and is reused across the .gitlab-ci.yml: first a rule for merge_request_event, then a rule for pushes to the default branch, then a rule for tags, and finally an explicit exclusion rule for everything else. This pattern can be defined as a YAML anchor and referenced from multiple jobs, so changes to the base logic only need to be maintained in one place instead of being synchronized separately across every job, which is exactly what separates a maintainable pipeline from a chaotic one once dozens of jobs are involved.
6. Common mistakes during the switch
The most common mistake is using changes conditions without the right pipeline type in mind: changes compares by default against the last successful pipeline run on the same ref, which often does not give the desired result on merge request pipelines, where comparing against the target branch makes more sense. GitLab offers an extended changes syntax with compare_to for this case, letting you explicitly specify a reference branch instead of relying on automatic detection. Anyone who overlooks this subtlety ends up either with jobs that fail to run despite relevant changes, or with jobs that run unnecessarily on every commit because the comparison point was chosen incorrectly.
A second typical mistake is assuming that rules entries act like independent filters that are all checked simultaneously. In reality GitLab stops at the first matching rule and ignores everything after it, which makes the order of rules critical. If a general if condition is accidentally placed before a more specific one, the general rule always wins and the specific one is never reached. The rule of thumb is therefore to sort rules from most specific to most general and always end with an explicit fallback rule, so the behavior for all uncovered cases is clearly documented rather than implicitly left to GitLab's default.
7. What teams typically gain after migrating
Teams that migrate from only/except to rules usually report two measurable effects: shorter pipeline files, because job duplicates disappear, and shorter average pipeline runtimes, because jobs with changes conditions can be limited to actually affected areas of the codebase. Especially in monorepos or in projects with clearly separated frontend and backend directories, a well configured changes condition saves substantial CI time, because a documentation-only change no longer runs the full application test suite. This effect is practically unreachable with only/except, since file-based conditions did not exist before rules was introduced.
The second, often underestimated effect concerns comprehensibility: a rules list with clear if/changes/exists conditions and an explicit fallback rule is significantly easier for new team members to read than scattered only/except blocks with silent assumptions about default behavior. In code review it is also immediately obvious when a condition is missing or a rule is too broad, because the entire logic lives in one place within the job rather than being spread across several attributes. For teams that regularly onboard new members or hand pipelines over to external contractors, this readability gain is often the real main reason for the migration, more so than the pure functional extension.
8. Tooling and validation during the switch
GitLab's CI/CD pipeline editor view in the project provides live validation of the .gitlab-ci.yml that flags syntax errors in rules blocks immediately, before a commit is even pushed. In addition, the visualization in the editor shows which jobs land in which stage, which helps spot accidentally misplaced jobs quickly during a migration. For more complex migrations it is worth using the CI Lint API, which simulates server-side exactly how GitLab would interpret a given .gitlab-ci.yml for a specific ref and pipeline source, without actually having to start a pipeline.
It is also advisable to open a temporary merge request solely to walk through all relevant trigger scenarios: a push to a feature branch, a push to main, setting a tag, and opening a merge request. For each scenario, document which jobs should run and which actually run before removing the old only/except code. This test pass may take extra effort, but it reliably prevents a migration from silently causing a deployment job to be skipped in production, which in practice is the most expensive possible mistake for this kind of refactor.
9. Conclusion: rules as the standard, only/except as legacy
only/except remains functional for compatibility reasons, but it is no longer a sensible choice for new pipelines, because rules covers every capability of only/except and additionally offers combinable conditions, granular when behavior and file-based changes checks. For existing pipelines a gradual, well tested migration pays off, because it not only opens up new possibilities but usually shortens and clarifies existing pipeline files considerably. Anyone who plans this migration carefully ends up with a pipeline that is easier to extend, debug and hand over to new team members.
The table below contrasts the key differences between only/except and rules once more, as a practical reference for planning your own migration.
| Aspect | only/except | rules | Practical recommendation |
|---|---|---|---|
| Combining conditions | not possible | if/changes/exists freely combinable | use rules for every multi-condition case |
| Default when no rule matches | job runs | job is skipped | always set an explicit fallback rule |
| File-based conditions | not supported | changes with paths and compare_to | use for monorepos and selective pipelines |
| when per condition | global for the job | individual per rule | use rules to target manual deployments precisely |
| Maintenance status at GitLab | legacy, no new features | actively developed | use rules exclusively for new pipelines |
Mironsoft
CI/CD pipelines, zero-downtime deployments and release automation
Deployments that run without downtime and without the nail-biting?
We review existing GitLab pipelines for fragile deployment steps and missing safeguards, then build a release process with zero-downtime deployments, automated checks and a rollback you can actually trust in an emergency.
Pipeline Review
Checking an existing .gitlab-ci.yml for fragility, missing stages and security gaps.
Zero-Downtime Deployment
Building symlink releases, health checks and rollback strategies for Magento stores.
CI/CD Automation
Connecting tests, security scans and deployments into one reliable pipeline.
10. Summary
rules vs. only/except: The essentials at a glance
Core problem with only/except
No way to combine multiple conditions, no file-based filtering, inconsistent implicit defaults depending on configuration.
Core advantage of rules
if, changes and exists freely combinable, individual when per rule, actively developed by GitLab.
Migration approach
Migrate job by job, document behavior beforehand, verify with debug jobs, only then remove the old block.
Most common pitfall
Missing explicit fallback rule at the end of the list, and choosing the wrong comparison point for changes.