Zoho CRM PostgreSQL Sync in Both Directions
How to keep Zoho CRM and PostgreSQL in agreement in near real time, without sync loops, lost updates or unresolved conflicts.

Build a two-way Zoho CRM PostgreSQL sync that prevents loops, resolves conflicts predictably and handles deletes, with a worked PostgreSQL example.
Many companies keep a PostgreSQL database alongside Zoho CRM: for a customer-facing application, an analytics warehouse, an operations system or a product backend. Sooner or later they need the two to agree. A one-way export is straightforward. A Zoho CRM PostgreSQL sync that runs in both directions, in near real time, is harder: updates must flow quickly, conflicts must be resolved predictably, and a change synced from one side must not bounce back and trigger an endless loop. This guide covers the architecture we use, how to prevent sync loops and conflicts, and a worked example of the database side.
When a two-way Zoho CRM PostgreSQL sync makes sense
Two-way sync is justified when both systems are actively edited: sales updates accounts and deals in the CRM, while an application or operations team updates related data in the database. If one system only reads, a one-way sync is simpler and safer. Start by listing each table or module, which side edits it, and how quickly changes need to appear on the other side.
The architecture
DirectionTriggerTransportWrite methodZoho CRM to PostgreSQLNotification API events or workflow webhooksIntake endpoint and queueUpsert into PostgreSQL keyed on the Zoho record IDZoho CRM to PostgreSQL (safety net)Scheduled delta syncGet Records with If-Modified-SinceSame upsertPostgreSQL to Zoho CRMDatabase trigger writes to an outbox tableWorker reads the outboxZoho upsert API with a unique field
From Zoho CRM
Zoho's Notification API sends events to your URL when records are created, updated or deleted, and can be limited to specific fields. Notification channels have an expiry time, so the integration must renew them. Because events can be missed during outages, a scheduled delta sync using the If-Modified-Since header on Get Records catches anything that slipped through.
From PostgreSQL
A trigger on each synced table writes a row to an outbox table whenever a relevant column changes. A worker reads the outbox, sends changes to Zoho with the upsert API, and marks rows as done. The outbox makes the process durable: if Zoho is unavailable, changes wait rather than disappear.
Preventing sync loops
A loop happens when a change synced from Zoho to PostgreSQL fires the database trigger, which sends the same change back to Zoho, which fires a notification, and so on. Three techniques stop it:
- Origin marking: when the sync writes to PostgreSQL, it sets a session variable or column indicating the change came from Zoho, and the trigger ignores those writes
- Content hashing: store a hash of the synced fields; if an incoming change produces the same hash as the stored one, there is nothing to do
- Integration user filtering: changes made in Zoho by the dedicated integration user are ignored when they arrive as notifications
Using at least two of these gives a margin of safety.
Resolving conflicts
When both sides change the same record before syncing, you need a rule. Options include:
- Field ownership: each field has one owning system, and only that side's changes are accepted
- Last writer wins: compare modification timestamps and keep the newer value
- Manual review: for high-value fields, flag conflicts for a person to resolve
Field ownership is the most predictable and is our default. Last writer wins is simple but depends on reliable clocks and can lose intended changes.
Worked example: the PostgreSQL side
The illustrative schema below stores accounts with their Zoho IDs, a hash of synced fields, and an outbox fed by a trigger that ignores writes made by the sync itself.
CREATE TABLE accounts (
id bigserial PRIMARY KEY,
zoho_id text UNIQUE,
name text NOT NULL,
industry text,
credit_status text, -- owned by PostgreSQL
sync_hash text,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE sync_outbox (
id bigserial PRIMARY KEY,
table_name text NOT NULL,
row_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
processed boolean NOT NULL DEFAULT false
);
CREATE OR REPLACE FUNCTION accounts_to_outbox() RETURNS trigger AS $$
BEGIN
IF current_setting('app.sync_origin', true) = 'zoho' THEN
RETURN NEW; -- change came from Zoho; do not echo it
END IF;
INSERT INTO sync_outbox (table_name, row_id) VALUES ('accounts', NEW.id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER accounts_outbox AFTER INSERT OR UPDATE OF credit_status ON accounts
FOR EACH ROW EXECUTE FUNCTION accounts_to_outbox();
Incoming changes from Zoho are applied with an upsert, using PostgreSQL's INSERT ... ON CONFLICT, inside a transaction that marks the origin:
import hashlib, json
def apply_from_zoho(conn, rec):
fields = {"name": rec["Account_Name"], "industry": rec.get("Industry")}
h = hashlib.sha256(json.dumps(fields, sort_keys=True).encode()).hexdigest()
with conn.transaction():
conn.execute("SET LOCAL app.sync_origin = 'zoho'")
conn.execute("""
INSERT INTO accounts (zoho_id, name, industry, sync_hash)
VALUES (%(zid)s, %(name)s, %(industry)s, %(h)s)
ON CONFLICT (zoho_id) DO UPDATE
SET name = EXCLUDED.name, industry = EXCLUDED.industry,
sync_hash = EXCLUDED.sync_hash, updated_at = now()
WHERE accounts.sync_hash IS DISTINCT FROM EXCLUDED.sync_hash
""", {"zid": rec["id"], "h": h, "name": fields["name"], "industry": fields["industry"]})
Here name and industry are owned by Zoho, and credit_status is owned by PostgreSQL, so only credit status changes go back to the CRM. The WHERE clause skips updates when nothing changed. This is a sketch: production code also handles deletes, batching and error logging.
Operating the sync
- Monitor queue depth, outbox backlog and the age of the oldest unprocessed change
- Alert when notification channels are close to expiry or have stopped delivering
- Respect Zoho API credits by batching upserts, up to 100 records per call
- Run a periodic reconciliation that compares record counts and hashes on both sides
- Keep a replay tool that can resend a record or a time range after an incident
Mapping fields and types
Zoho and PostgreSQL represent data differently. Picklists become text or enumerated types, lookups become foreign keys that must be resolved through the Zoho ID, multi-select fields become arrays or join tables, and date-time values need consistent time zones. Write the mapping down per field: source, target, type conversion, owner and whether it syncs in one or both directions. That document becomes the reference for developers and for anyone investigating a mismatch later, and it makes adding fields a routine change rather than a rediscovery exercise.
Rolling it out
Start one-way from Zoho to PostgreSQL, verify the data, then enable the reverse direction for a single owned field. Expand field by field, watching for loops and conflicts in the logs. Run an initial full load before switching on real-time events, so both sides start from the same state. Keep the first weeks under close watch, reviewing the logs daily for conflicts, retries and skipped updates.
Handling deletes and merges
Deletes and merges are easy to forget. When a record is deleted in Zoho, decide whether the database row is deleted, archived or flagged. When duplicates are merged in the CRM, the surviving record keeps its ID while the others disappear, so the database must re-point related rows to the survivor. Test both cases explicitly, because they are where two-way syncs most often drift apart.
For integrations involving finance systems, see our guide to Zoho CRM ERP integration. If you are replacing fragile connector-platform flows with this kind of sync, our comparison of Zapier and custom API integration explains the trade-offs.
Frequently Asked Questions
Can Zoho CRM sync with PostgreSQL in real time?
Yes. Zoho's Notification API or workflow webhooks can push changes as they happen, with a scheduled delta sync as a safety net. Changes from PostgreSQL can be sent through an outbox and the upsert API.
How do we prevent infinite sync loops?
Mark the origin of each change, compare content hashes and ignore changes made by the integration user, so synced updates are not sent back.
What happens if both systems change the same record?
Apply a conflict rule. Field ownership, where each field has one owning system, is the most predictable option.
Do we need the Notification API, or are webhooks enough?
Workflow webhooks work for specific events. The Notification API covers create, update and delete events per module and can target specific fields, which suits general-purpose sync.
Connect Zoho CRM and your database with us
We build reliable two-way syncs between Zoho CRM and PostgreSQL with loop prevention, conflict rules and monitoring. See our CRM and API integration services, or talk to us about your sync.
About the Author
Manav Sharma is the founder of Insyrge and a CRM solutions architect. He works across Zoho, Salesforce, HubSpot, Odoo and more, designing the CRM setups, integrations and automations that keep sales and operations teams working from the same data.
Need Help Implementing This in Your Business?
Our certified Zoho consultants and automation experts can help you design and deploy custom workflows tailored to your operations.
Book Free Consultation