GitLab CI rules vs. only/except: migration and practice
AI generated
CI/CD
.yml
GitLab · CI/CD · DevOps
GitLab CI rules vs. only/except
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.

17 min read rules keyword only/except migration .gitlab-ci.yml

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.

11. FAQ: rules vs. only/except: The essentials at a glance

1Does only/except still work in GitLab CI?
Yes, only and except still work and are not removed immediately. GitLab classifies them as legacy keywords that no longer receive new capabilities, while rules is actively developed further. New pipelines should therefore use rules exclusively.
2Can I mix only/except and rules in the same job?
No, GitLab allows either only/except or rules per job, not both at the same time. A job must commit to one of the two systems. Within a single .gitlab-ci.yml, different jobs can use different systems, which is useful during a gradual migration.
3What happens if no rules entry matches?
If none of the defined rules match, the job is skipped by default. This is the opposite of the only/except behavior, where a job without an explicit restriction normally runs. Every rules list should therefore be consciously closed with an explicit fallback rule.
4How does changes compare against the right base?
By default, changes compares against the last successful pipeline run on the same ref. On merge request pipelines, comparing against the target branch is often more sensible, which can be configured explicitly using the extended changes syntax with the compare_to parameter.
5Why do some jobs run twice after switching to merge request pipelines?
This happens when both a branch pipeline and a merge request pipeline are triggered for the same commit. A top-level workflow:rules configuration that suppresses branch pipelines when an open merge request already exists resolves this reliably.
6Is rules slower to evaluate than only/except?
No, the evaluation overhead is minimal and not measurable in practice. GitLab checks the rules sequentially and stops at the first match, which takes only milliseconds even with many rules and has no relevant impact on overall pipeline runtime.
7Can I reuse rules blocks between jobs?
Yes, YAML anchors combined with the merge key syntax using <<: *anchor_name let you reference identical rules lists in multiple places. This significantly reduces redundancy when many jobs share the same basic condition logic.
8What is the difference between exists and changes?
changes checks whether files have changed since the comparison point, while exists only checks whether certain files are present in the current repository state at all, regardless of changes. exists is useful for jobs that should only run when a specific configuration file exists.
9Should I migrate in one large commit or many small ones?
Small, job-by-job commits are significantly safer, because errors can be traced in isolation and tested specifically in merge request pipelines before the old only/except block is removed. A single large migration commit makes debugging considerably harder.
10Is the migration worth it for small pipelines with few jobs?
For very small pipelines with clear, unchanging conditions the benefit is smaller, but even there you gain from clearer readability and future-proofing in case the pipeline grows later. With more than five jobs and varying conditions, the migration is generally well worth it.