Moving PostgreSQL's data directory is straightforward only when every assumption is correct: the intended cluster is stopped, the final copy is consistent, the service reads the configuration you edited, ownership survived, and the old directory remains available for rollback. This guide packages those requirements into a guarded Debian/Ubuntu procedure and a working Bash script.
A small gift from our team: the script below is available to copy or download directly. Read the scope and rehearse it on a restored environment before using it in production.
Scope: where this script works
The automated example targets PostgreSQL installed from Debian/Ubuntu packages, managed as a named cluster by postgresql-common and systemd. Its arguments are the PostgreSQL major version, cluster name, and new absolute data path:
sudo bash move-postgresql-data-directory-debian.sh 16 main /data/postgresql/16/main
The script refuses a destination on the same filesystem by default, which catches the common case where /data exists but its disk was not mounted. If a same-filesystem path change is genuinely intentional, run with ALLOW_SAME_FILESYSTEM=1 after checking capacity.
It is intentionally not a universal script:
- For RHEL, Rocky, AlmaLinux, or source installations, the service usually locates the cluster through
PGDATAor-D; adapt the service override instead of assuming Debian's external configuration layout. - For Docker or Kubernetes, move or replace the volume/PVC and follow the orchestrator's ownership and rollout model.
- Amazon RDS, Aurora, Cloud SQL, and Azure Database for PostgreSQL do not expose host-level
PGDATA; resize or migrate through the provider. - This procedure moves files for the same PostgreSQL major version. It is not
pg_upgradeand not a cross-architecture migration.
How Debian/Ubuntu finds the data directory
On these packages, configuration normally lives under /etc/postgresql/VERSION/CLUSTER/, while data lives under /var/lib/postgresql/VERSION/CLUSTER/. The service starts from the external configuration directory, and the data_directory setting points at the actual storage path. PostgreSQL applies that parameter only at server start.
The script refuses to continue if pg_settings.sourcefile says data_directory came from another included file or from a different mechanism. That guard is deliberate: silently editing the wrong file is how a clean copy turns into a failed restart.
Before the maintenance window
- Take a valid backup and prove you can restore it. Keeping the old directory helps rollback but does not replace backup.
- Mount the destination persistently by UUID or stable device identifier and test
mount -a. A directory that exists on the root filesystem when the disk fails to mount can make PostgreSQL write to the wrong device. - Confirm the target filesystem supports PostgreSQL's durability semantics and has enough capacity, inodes, and expected IOPS.
- Pause configuration management that could overwrite
postgresql.confduring the change. - Drain application traffic and schedule a restart window. The first copy runs online; the final synchronization runs after a clean stop.
findmnt /data
df -hT /data
df -ih /data
sudo -u postgres psql -XAtqc "SHOW data_directory"
sudo -u postgres psql -XAtqc "SHOW config_file"
Tablespaces are separate: directories referenced through PGDATA/pg_tblspc are symbolic links. The script preserves those links; it does not copy the external tablespace contents. Inventory pg_tablespace separately if those volumes must also move.
What the script does
Two-pass migration: short downtime, explicit rollback
The online pass is only a staging copy; it is not a usable backup by itself. Consistency comes from the second rsync after a clean service stop. The script then edits the active Debian configuration, starts the same cluster, queries SHOW data_directory, and rolls the configuration back automatically if any command in the cutover phase fails.
Complete migration script
Save as move-postgresql-data-directory-debian.sh, or download the ready-to-run file:
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
echo "Usage: sudo $0 <major_version> <cluster> <new_data_directory>"
echo "Example: sudo $0 16 main /data/postgresql/16/main"
}
fail() {
echo "ERROR: $*" >&2
exit 1
}
log() {
echo "[$(date --iso-8601=seconds)] $*"
}
[[ $EUID -eq 0 ]] || fail "Run this script as root."
[[ $# -eq 3 ]] || { usage; exit 2; }
PG_VERSION="$1"
PG_CLUSTER="$2"
REQUESTED_DATA="$3"
SERVICE="postgresql@${PG_VERSION}-${PG_CLUSTER}.service"
CONFIG="/etc/postgresql/${PG_VERSION}/${PG_CLUSTER}/postgresql.conf"
[[ "$PG_VERSION" =~ ^[0-9]+$ ]] || fail "Invalid PostgreSQL major version."
[[ "$PG_CLUSTER" =~ ^[A-Za-z0-9_-]+$ ]] || fail "Invalid cluster name."
[[ "$REQUESTED_DATA" =~ ^/[A-Za-z0-9._/-]+$ ]] || fail "The new path must be absolute and use only letters, numbers, dot, underscore, slash, or dash."
[[ "$REQUESTED_DATA" != "/" ]] || fail "Refusing to use / as PGDATA."
for command_name in pg_lsclusters psql realpath rsync runuser systemctl; do
command -v "$command_name" >/dev/null || fail "Missing command: $command_name"
done
[[ -f "$CONFIG" ]] || fail "Configuration not found: $CONFIG"
systemctl is-active --quiet "$SERVICE" || fail "$SERVICE is not active."
PORT="$(pg_lsclusters --no-header | awk -v version="$PG_VERSION" -v cluster="$PG_CLUSTER" '$1 == version && $2 == cluster { print $3; exit }')"
[[ "$PORT" =~ ^[0-9]+$ ]] || fail "Could not determine the cluster port."
PSQL=(runuser -u postgres -- psql -XAt --no-password --port="$PORT" --dbname=template1)
OLD_DATA="$("${PSQL[@]}" --command='SHOW data_directory')"
SETTING_SOURCE="$("${PSQL[@]}" --command="SELECT sourcefile FROM pg_settings WHERE name = 'data_directory'")"
[[ -n "$OLD_DATA" ]] || fail "Could not read the current data_directory."
[[ -n "$SETTING_SOURCE" ]] || fail "data_directory is not sourced from a configuration file."
OLD_DATA="$(realpath -e "$OLD_DATA")"
NEW_DATA="$(realpath -m "$REQUESTED_DATA")"
CONFIG_REAL="$(realpath -e "$CONFIG")"
SETTING_SOURCE_REAL="$(realpath -e "$SETTING_SOURCE")"
[[ "$SETTING_SOURCE_REAL" == "$CONFIG_REAL" ]] || fail "data_directory comes from $SETTING_SOURCE_REAL, not $CONFIG_REAL. Update this runbook for that layout."
[[ "$OLD_DATA" != "$NEW_DATA" ]] || fail "The source and destination are identical."
[[ -f "$OLD_DATA/PG_VERSION" ]] || fail "PG_VERSION is missing from $OLD_DATA."
[[ "$(tr -d '[:space:]' < "$OLD_DATA/PG_VERSION")" == "$PG_VERSION" ]] || fail "The source data directory belongs to another PostgreSQL major version."
case "$NEW_DATA/" in
"$OLD_DATA/"*) fail "The destination cannot be inside the current data directory." ;;
esac
if [[ -d "$NEW_DATA" ]] && [[ -n "$(find "$NEW_DATA" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
fail "The destination exists and is not empty: $NEW_DATA"
fi
install -d -o postgres -g postgres -m 700 "$NEW_DATA"
REQUIRED_KB="$(du -sk "$OLD_DATA" | awk '{ print $1 }')"
AVAILABLE_KB="$(df -Pk "$NEW_DATA" | awk 'NR == 2 { print $4 }')"
MINIMUM_KB=$(( REQUIRED_KB + REQUIRED_KB / 10 ))
(( AVAILABLE_KB >= MINIMUM_KB )) || fail "Insufficient space: need at least ${MINIMUM_KB} KiB, have ${AVAILABLE_KB} KiB."
OLD_DEVICE="$(df -P "$OLD_DATA" | awk 'NR == 2 { print $1 }')"
NEW_DEVICE="$(df -P "$NEW_DATA" | awk 'NR == 2 { print $1 }')"
if [[ "$OLD_DEVICE" == "$NEW_DEVICE" && "${ALLOW_SAME_FILESYSTEM:-0}" != "1" ]]; then
fail "Source and destination are on the same filesystem ($OLD_DEVICE). Is the new disk mounted? Set ALLOW_SAME_FILESYSTEM=1 only if this is intentional."
fi
log "Source: $OLD_DATA"
log "Destination: $NEW_DATA"
log "Starting online staging copy. This pass is not a backup."
ONLINE_RSYNC_STATUS=0
rsync -aH --numeric-ids --delete "$OLD_DATA/" "$NEW_DATA/" || ONLINE_RSYNC_STATUS=$?
if (( ONLINE_RSYNC_STATUS != 0 && ONLINE_RSYNC_STATUS != 24 )); then
fail "The online rsync failed with status $ONLINE_RSYNC_STATUS."
fi
if (( ONLINE_RSYNC_STATUS == 24 )); then
log "Files changed during the online copy (rsync status 24); the offline pass will reconcile them."
fi
TIMESTAMP="$(date +%Y%m%d%H%M%S)"
CONFIG_BACKUP="${CONFIG}.before-pgdata-move.${TIMESTAMP}"
cp -a -- "$CONFIG" "$CONFIG_BACKUP"
ROLLBACK_REQUIRED=1
rollback() {
local exit_code=$?
trap - EXIT
if (( ROLLBACK_REQUIRED )); then
echo "Cutover failed; restoring $CONFIG_BACKUP" >&2
systemctl stop "$SERVICE" || true
cp -a -- "$CONFIG_BACKUP" "$CONFIG"
systemctl start "$SERVICE" || echo "Automatic restart on the old directory failed; inspect journalctl -u $SERVICE." >&2
fi
exit "$exit_code"
}
trap rollback EXIT
log "Stopping $SERVICE for the final synchronization."
systemctl stop "$SERVICE"
systemctl is-active --quiet "$SERVICE" && fail "$SERVICE did not stop."
log "Running final offline synchronization."
rsync -aH --numeric-ids --delete "$OLD_DATA/" "$NEW_DATA/"
chown postgres:postgres "$NEW_DATA"
chmod 700 "$NEW_DATA"
if grep -Eq '^[[:space:]]*data_directory[[:space:]]*=' "$CONFIG"; then
sed -Ei "s|^[[:space:]]*data_directory[[:space:]]*=.*$|data_directory = '${NEW_DATA}'|" "$CONFIG"
else
printf "
data_directory = '%s'
" "$NEW_DATA" >> "$CONFIG"
fi
log "Starting $SERVICE with the new data directory."
systemctl start "$SERVICE"
ACTUAL_DATA="$("${PSQL[@]}" --command='SHOW data_directory')"
ACTUAL_DATA="$(realpath -e "$ACTUAL_DATA")"
[[ "$ACTUAL_DATA" == "$NEW_DATA" ]] || fail "PostgreSQL started with $ACTUAL_DATA instead of $NEW_DATA."
"${PSQL[@]}" --command='SELECT version();'
"${PSQL[@]}" --command='SELECT pg_is_in_recovery();'
ROLLBACK_REQUIRED=0
trap - EXIT
log "Migration completed successfully."
log "Configuration backup: $CONFIG_BACKUP"
log "Old data retained at: $OLD_DATA"
log "Do not remove the old directory until backup, application, logs, and replicas are verified."
Run it and watch the cutover
chmod 750 move-postgresql-data-directory-debian.sh
sudo ./move-postgresql-data-directory-debian.sh 16 main /data/postgresql/16/main
sudo systemctl status postgresql@16-main.service --no-pager
sudo journalctl -u postgresql@16-main.service -n 100 --no-pager
Most bytes move during the online pass. The downtime contains the final delta, configuration change, restart, and verification query. Write-heavy clusters produce a larger delta, so schedule accordingly.
Post-migration verification
sudo -u postgres psql -X -d template1 -c "SHOW data_directory;"
sudo -u postgres psql -X -d template1 -c "SHOW config_file;"
sudo -u postgres psql -X -d template1 -c "SELECT datname, pg_size_pretty(pg_database_size(datname)) FROM pg_database ORDER BY 1;"
findmnt /data
df -hT /data
sudo ss -ltnp | grep postgres
- Run application read/write smoke tests, not just
SELECT 1. - Inspect PostgreSQL logs for missing files, permission failures, or recovery messages.
- Verify physical and logical replication, archiving, backup jobs, monitoring, and any path-based security policy.
- Restart the host during a scheduled validation window to prove the destination mount and service survive a boot.
Rollback while the old directory is retained
The script automatically restores the old configuration when the cutover itself fails. If a later application test finds a problem, stop writes before rolling back; once clients have written to the new directory, the old copy is stale.
sudo systemctl stop postgresql@16-main.service
sudo cp -a /etc/postgresql/16/main/postgresql.conf.before-pgdata-move.TIMESTAMP /etc/postgresql/16/main/postgresql.conf
sudo systemctl start postgresql@16-main.service
sudo -u postgres psql -XAtqc "SHOW data_directory"
Do not “merge” two PGDATA directories. If the new cluster accepted writes, either resolve the issue in place or use a database-aware recovery/migration plan. File-level rsync from a running or newer cluster back into the old directory is not a safe conflict-resolution method.
When can the old directory be removed?
Only after the new mount has survived a restart, application checks are green, backups and replicas have succeeded, monitoring reports the expected instance, and your rollback-retention window has expired. Then stop PostgreSQL briefly or otherwise verify no process uses the old path, archive whatever policy requires, and remove it through your controlled change process. The supplied script never deletes the old directory.
Keep the new storage observable
A correct path change can still create a future incident if the new filesystem is absent after reboot, grows faster than forecast, or delivers different latency. PG Monitoring correlates storage growth and I/O behavior with queries, checkpoints, WAL, bloat, and replication, so the team can prove the move improved the system instead of merely changing a pathname.
References: PostgreSQL's file-location documentation explains the relationship between PGDATA, -D, config_file, and data_directory; the postgres reference documents PGDATA as the default data-directory location.