HOSTING SINCE 2007 · 60-SEC CHECKSIN BUSINESS SINCE 1998 · GREENTREE SINCE 2007 · SOUTHBURY, CT
GREENTREE1998 · GT 2007
Web Applications · Cloud · Enterprise Email
Scope a Project
ALL SYSADMIN FIELD NOTES & RUNBOOKS
FN-2026-07 // HA DATABASE OPSDNS & Linux Infrastructure · 8 min read

Writable Standby PostgreSQL Failback Merges: Why ON CONFLICT (id) DO NOTHING Silently Drops Rows on Sequential IDs (And How to Fix It With UUIDs or Split Sequence Ranges)

When you run a writable warm-standby PostgreSQL database so contact forms and quote requests still save during primary maintenance, merging those standby rows back with ON CONFLICT (id) DO NOTHING looks safe—until you realize what happens if both servers hand out sequential integer IDs.

Published: 2026-10-25•Last tested: 2026-10-25•Tested on PostgreSQL 16.x (Ubuntu 24.04 LTS) with built-in core gen_random_uuid() (UUIDv4, native since PG 13) and partitioned IDENTITY sequences
01 // THE SYMPTOM

A form submission or lead saved on the warm-standby database during a brief failover disappears without any SQL error when the failback script merges standby rows back to the primary.

02 // THE ROOT CAUSE

The table uses a standard sequential integer primary key (SERIAL or GENERATED BY DEFAULT AS IDENTITY) starting at the same counter on both servers. A row created on the standby gets id = 412 while a different row on the primary already has id = 412—so ON CONFLICT (id) DO NOTHING silently skips the standby row.

03 // THE PERMANENT FIX

Audit every table in the failback merge list: use UUID primary keys (DEFAULT gen_random_uuid()) on append-only lead/submission tables, or configure non-overlapping odd/even sequence steps (INCREMENT BY 2) dynamically above MAX(id)—and check secondary unique constraints and payload hashes before skipping.

PREFERRED PRIMARY KEY
UUIDv4 (PG 13+)
Built-in gen_random_uuid() makes 128-bit ID collisions astronomically improbable across nodes (native uuidv7() in PG 18+)
INTEGER FALLBACK
Odd / Even Step
Primary uses INCREMENT BY 2 (odd IDs); Standby uses INCREMENT BY 2 (even IDs) seeded dynamically above MAX(id)
MERGE SCOPE
New Inserts Only
Append-only merge reconciles new form submissions; row edits require NTP-synced updated_at LWW logic
SECONDARY CONSTRAINTS
Unique & Audit
Account for secondary UNIQUE columns (e.g. email) and log payload mismatches so conflicts are never silent
Sysadmin Safety Notice: Test sequence step alterations and failback merge scripts on a staging database snapshot first. Never run ON CONFLICT (id) DO NOTHING across two active/standby databases until you have verified that their primary key generators cannot assign the same ID to two different rows.
INTERACTIVE SYSADMIN RUNBOOK · SAVED IN YOUR BROWSER

Interactive Standby PostgreSQL Failback Merge Audit Checklist

Run through these 6 checks before enabling automated standby-to-primary row reconciliation on any PostgreSQL database:

PROGRESS: 0 OF 6 STEPS VERIFIED (0%)CLICK ANY STEP TO CHECK OFF
FIELD NOTE 01 // THE SILENT COLLISION TRAP

1. Why ON CONFLICT (id) DO NOTHING Can Cause the Exact Data Loss You Built It to Prevent

Suppose you run a primary PostgreSQL 16 server and a warm-standby PostgreSQL 16 server. During a brief network hiccup or maintenance window, your standby server accepts a new customer quote request and inserts it into the leads table. Five minutes later, your reconciliation script connects over the encrypted mesh and runs INSERT INTO leads ... ON CONFLICT (id) DO NOTHING to merge any new standby rows back to the primary.

At first glance, ON CONFLICT (id) DO NOTHING looks completely idempotent: if the row is already on the primary, skip it; if it is new, insert it. But that assumption only holds if id uniquely identifies the same logical submission across both servers.

If your leads table uses a standard sequential integer (SERIAL or BIGSERIAL) and both databases were cloned from the same nightly snapshot at id = 411, then: • 1. On the Primary: A visitor on an ISP with a cached DNS record submits a form and gets assigned id = 412. • 2. On the Standby: A second visitor routed to the standby submits a completely different form and also gets assigned id = 412. • 3. During Failback Merge: When the merge script runs INSERT INTO leads ... ON CONFLICT (id) DO NOTHING, PostgreSQL sees that id = 412 already exists on the primary, silently skips the standby row without raising an error, and drops the second customer’s lead on the floor!

Primary Key StrategyBehavior During Standby Failback MergeProduction Safety Verdict
Unpartitioned SERIAL / IDENTITY (1, 2, 3...)Both nodes assign the same next integer to different submissionsUNSAFE — ON CONFLICT (id) DO NOTHING silently drops colliding rows
UUID Primary Key (gen_random_uuid())Each node generates an independent 128-bit UUID per submissionSAFE FOR NEW INSERTS — Collisions are astronomically improbable
Odd/Even Split Sequences (INCREMENT BY 2)Primary issues 1, 3, 5...; Standby issues 2, 4, 6...SAFE FOR NEW INSERTS — Integer IDs never overlap between nodes
In-Place Row Edits (UPDATE existing row)ON CONFLICT DO NOTHING ignores updates to existing IDsRequires NTP-synced clocks (chronyd) + ON CONFLICT DO UPDATE WHERE EXCLUDED.updated_at > ...
Secondary UNIQUE Constraints (e.g. UNIQUE(email))Can still raise constraint errors even when primary key IDs do not collideKeep form/lead tables append-only without secondary unique indexes, or handle the secondary constraint explicitly
FIELD NOTE 02 // AUDITING & FIXING PRIMARY KEYS

2. Auditing Every Table’s ID Type and Configuring UUIDs or Dynamic Odd/Even Sequence Parity

Before you claim that a standby database merges form submissions back cleanly, you have to inspect the actual schema of every table in your fleet (information_schema.columns)—and watch out for two subtle traps that catch even experienced DBAs.

First, for greenfield tables—especially contact forms, quote requests, pin-board notes, and audit logs—UUID (id UUID PRIMARY KEY DEFAULT gen_random_uuid()) is the cleanest choice. Since PostgreSQL 13, gen_random_uuid() is built directly into core PostgreSQL (no pgcrypto extension required) and generates 128-bit UUIDv4 values (while native time-ordered uuidv7() arrived in PostgreSQL 18), making ID collisions across independent nodes astronomically improbable. Also ensure that append-only form submission tables do not carry secondary UNIQUE constraints (such as UNIQUE (email)), which would still raise a constraint error if the same customer submits twice.

Second, for existing tables that use integer SERIAL or BIGINT primary keys, never hard-code a fixed sequence restart like RESTART WITH 10001 (which will immediately fail with duplicate-key errors if the table already has rows above 10,001), and never run a plain setval(seq, MAX(id)) after a failback merge. Why? Because once the primary table merges in the standby’s even-numbered rows, MAX(id) on the primary might be an even number! If you set the primary’s INCREMENT BY 2 sequence to an even MAX(id), the primary will start handing out even numbers (MAX(id) + 2)—colliding directly with the standby’s range! Always calculate the parity offset explicitly from MAX(id) as shown below.

PostgreSQL 16 Commands to Audit Primary Keys and Seed Dynamic Odd/Even Sequence Parity Above MAX(id)
-- 1. Audit primary key column types and defaults across all public tables
SELECT
  tc.table_name,
  kcu.column_name,
  c.data_type,
  c.column_default,
  c.is_identity
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON tc.constraint_name = kcu.constraint_name
  AND tc.table_schema = kcu.table_schema
JOIN information_schema.columns c
  ON c.table_schema = tc.table_schema
  AND c.table_name = tc.table_name
  AND c.column_name = kcu.column_name
WHERE tc.constraint_type = 'PRIMARY KEY'
  AND tc.table_schema = 'public'
ORDER BY tc.table_name;

-- 2. Option A (Preferred for Lead/Form Tables): Core Built-In UUIDv4 Primary Key (No pgcrypto Needed in PG 13+)
CREATE TABLE IF NOT EXISTS public.form_submissions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  site_domain TEXT NOT NULL,
  submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  origin_node TEXT NOT NULL DEFAULT 'primary',
  payload JSONB NOT NULL
);

-- 3. Option B (For Legacy Integer Tables): Dynamic Odd/Even Parity Above Current MAX(id)
-- Run on PRIMARY node (nextval() is guaranteed to be the first ODD integer above MAX(id), even if MAX(id) is even):
ALTER SEQUENCE public.leads_id_seq INCREMENT BY 2;
SELECT setval(
  'public.leads_id_seq',
  COALESCE((SELECT MAX(id) - (1 - (MAX(id) % 2)) FROM public.leads), 1),
  true
);

-- Run on STANDBY node (nextval() is guaranteed to be the first EVEN integer above MAX(id), even if MAX(id) is odd):
ALTER SEQUENCE public.leads_id_seq INCREMENT BY 2;
SELECT setval(
  'public.leads_id_seq',
  COALESCE((SELECT MAX(id) - (MAX(id) % 2) FROM public.leads), 2),
  true
);
⚠ Test on a non-production system first and verify backups before running administrative commands.
SYSADMIN Q&A

Common Questions on Standby PostgreSQL ID Collisions & UUID/Sequence Fixes

Does an append-only standby merge also synchronize edits (UPDATEs) to existing rows?

No. An INSERT ... ON CONFLICT DO NOTHING reconciliation script only merges newly created rows (such as contact form submissions, quote requests, and new pin-board entries). If an application allows users to edit existing records during a failover window, those tables require an updated_at timestamp column, an explicit ON CONFLICT (id) DO UPDATE WHERE EXCLUDED.updated_at > table.updated_at rule, and tightly synchronized system clocks via NTP (chronyd) on both servers so clock skew never causes an older edit to overwrite a newer one.

Why can’t you just run setval(seq, MAX(id)) after merging standby rows or restoring a dump?

Because after a failback merge, the primary table now contains the standby’s even-numbered IDs, so MAX(id) on the primary can be even! If you run a plain setval(seq, MAX(id)) on a sequence with INCREMENT BY 2, the primary will start issuing even numbers (MAX(id) + 2)—putting it right into the standby’s even range. Instead, use explicit parity math: setval(seq, COALESCE(MAX(id) - (1 - MAX(id) % 2), 1), true) on the Primary so the next ID is always the first odd number above MAX(id), and setval(seq, COALESCE(MAX(id) - (MAX(id) % 2), 2), true) on the Standby so its next ID is always the first even number above MAX(id).

How can I verify that no standby rows were skipped due to a collision?

Before inserting from the standby staging table into the primary table, run a join on id where the payload or created_at timestamps differ. If any row shares an id with a different primary record, insert it with a newly generated ID or log it into a conflict review table rather than silently dropping it.

COMPANION CORNERSTONE REFERENCE GUIDE (/LIBRARY)

Inside GreenTree’s High-Availability Cloud Stack: Multi-Node NVMe Linux, Encrypted WireGuard Mesh, Authoritative 60-Second DNS & Standby Database Failback

Read the complete 3,900+ word architectural walkthrough of our active/warm-standby Ubuntu 24.04 LTS NVMe cloud, encrypted private mesh, and standby PostgreSQL failback design.

Read Full Engineering Guide
HIGH-AVAILABILITY CLOUD & POSTGRESQL

Want Your Website & Database on Our Monitored Linux & PostgreSQL Cloud?

Our $99/mo Core Cloud and $199/mo Core Cloud + Management plans include a dedicated PostgreSQL 16 database, 60-second external monitoring, and 1 SmarterMail Enterprise mailbox—with multi-node warm-standby failover and off-node replication available for high-availability deployments.

RELATED FIELD NOTES & RUNBOOKS

Related Notes From the Sysadmin Desk

View All 8 Field Notes