A PostgreSQL downgrade request often arrives disguised as ordinary configuration management: pin the package to an older version and make Ansible converge. That description leaves out the dangerous part. There are at least three versions in play—the RPM build, the PostgreSQL major version, and the data directory format—and they do not have the same rollback rules.

Safety boundary: automation may prepare and validate a downgrade, but it should not cross a data-format boundary merely because a package module has allow_downgrade.

Classify the transition first

A downgrade from one RPM release to another within the same PostgreSQL major line is different from moving from PostgreSQL 16 to 15. Even a same-major change can include extension ABI changes, packaging script behavior, security fixes, or altered dependencies. A major-version downgrade cannot reuse a data directory created or upgraded by a newer major server.

Record these before changing anything:

  • installed package name, epoch, version, release, architecture, and repository source;
  • target NEVRA and whether it is still available from an approved repository;
  • server-reported version from SELECT version();
  • data directory and PG_VERSION value;
  • installed extensions and their versions;
  • replication role, backup status, and recovery objective;
  • applications sharing the instance and the allowed outage window.

If those facts are unknown, the correct automated action is to stop.

Build a read-only preflight

- name: Gather installed RPM facts
  ansible.builtin.package_facts:
    manager: rpm

- name: Require an explicit downgrade approval
  ansible.builtin.assert:
    that:
      - postgresql_downgrade_approved | default(false) | bool
      - postgresql_target_nevra | length > 0
      - postgresql_backup_reference | length > 0
    fail_msg: >-
      PostgreSQL downgrade requires an approved target NEVRA and a verified
      backup reference. This role will not infer either value.

Do not hide approval in a generic force variable. Name the dangerous operation. Make the target exact. Capture the transaction plan with DNF before execution so dependency removals or unexpected module-stream changes are visible during review.

Repository availability is part of rollback

DNF can only install an older build if the repository still exposes it or an internal snapshot retains it. “We can run history undo” is not a recovery plan. Red Hat explicitly notes that an undo fails when older packages are unavailable and warns that downgrading core RHEL system packages through transaction history is unsupported.

dnf --showduplicates list postgresql\*
dnf repoquery --nevra 'postgresql16*'
dnf history info <transaction-id>

For controlled estates, retain repository snapshots or exact approved artifacts with checksums. Otherwise the rollback procedure depends on mutable external state.

Use allow_downgrade narrowly

The Ansible DNF module documents that allow_downgrade: true can make the transaction non-idempotent because dependency resolution may leave a package set different from the requested list. That option is permission for DNF to consider an older build. It is not a declaration that the result is safe.

- name: Install the approved PostgreSQL package set
  ansible.builtin.dnf:
    name: "{{ postgresql_target_packages }}"
    state: present
    allow_downgrade: true
    update_only: true
  when:
    - postgresql_transition_class == 'same-major-package-release'
    - postgresql_downgrade_approved | bool
  notify: Restart PostgreSQL

Use exact versions for the complete coordinated package set: server, client, libraries, contrib modules, and relevant extensions. Test the dependency transaction in a matching environment. Be suspicious if the solver wants to erase unrelated packages or switch module streams.

Keep data migration out of the package task

For a major-version rollback, restore a backup made in a compatible format, rebuild the older cluster, or use a deliberately designed logical migration path. Do not point the older server binary at the newer data directory and hope a successful systemd start validates it.

The package role should make this boundary obvious:

- name: Block unsupported major-version downgrade
  ansible.builtin.fail:
    msg: >-
      Refusing PostgreSQL {{ installed_pg_major }} to {{ target_pg_major }}
      package downgrade. Restore or logical data migration must be executed
      through the database recovery runbook.
  when: installed_pg_major | int > target_pg_major | int

This is not a lack of automation. It is automation enforcing the point where a different recovery procedure and different owner must take control.

Control the operational sequence

  1. Confirm backup completion and perform a restore test before the maintenance window.
  2. Quiesce application writes and verify active sessions.
  3. Stop PostgreSQL cleanly and capture logs plus the final server state.
  4. Apply the reviewed package transaction.
  5. Start the service and inspect startup logs—not only the unit exit status.
  6. Validate SQL connectivity, server version, extensions, application queries, and replication.
  7. Only then release application traffic.

Handlers are convenient, but a risky transition deserves explicit blocks and validation tasks. If the post-check fails, keep the application stopped and surface the recovery runbook. An automatic “rollback” can make matters worse when the previous artifacts or data state are no longer compatible.

Define idempotence honestly

A normal package role can converge repeatedly. A downgrade is a controlled transition with preconditions, a one-time approval, and a postcondition. After the transition succeeds, ordinary convergence should take over using the pinned package state. Leaving allow_downgrade enabled forever expands the blast radius of every future run.

References: the Ansible DNF module documents the idempotence caveat; Red Hat’s DNF documentation describes transaction rollback constraints. For review or implementation work, see Ansible and Linux automation.