Skip to main content
Insyrge - Zoho CRM & Business Automation Consulting
← Back to All ArticlesAI & Business Automation

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.

•Manav Sharma
Zoho CRM PostgreSQL Sync in Both Directions

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