> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sequenzy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Warehouse Sync

> Import contacts and events from Snowflake, BigQuery, Redshift or Postgres on a schedule, sending only rows that changed.

# Warehouse Sync

Warehouse Sync keeps Sequenzy in step with your data warehouse. You write a SQL query, map its columns, and Sequenzy runs it on a schedule: contacts are created or updated, and events are recorded on your contacts. Each run sends only rows that changed since the last run.

Set it up in **Settings → Data Warehouse**, or with the [API](/api-reference/warehouse/connections-create), the [CLI](/concepts/cli) (`sequenzy warehouse`) or the [MCP server](/concepts/mcp).

<Tip>
  Sending data the other way? [Data exports](/integrations/data-exports) stream
  email events and subscriber snapshots to your bucket for loading into the same
  warehouse.
</Tip>

## 1. Create a read-only user

Sequenzy only runs `SELECT` queries, and on Postgres and Redshift every query runs in a read-only transaction. Still, give it a user that can only read what you want to sync.

<Tabs>
  <Tab title="Snowflake">
    Sequenzy uses key-pair authentication. Generate a key and assign its public half to a new user:

    ```bash theme={null}
    openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -out sequenzy_key.p8 -nocrypt
    openssl rsa -in sequenzy_key.p8 -pubout -out sequenzy_key.pub
    ```

    ```sql theme={null}
    CREATE ROLE SEQUENZY_READ;
    GRANT USAGE ON WAREHOUSE ANALYTICS_WH TO ROLE SEQUENZY_READ;
    GRANT USAGE ON DATABASE PROD TO ROLE SEQUENZY_READ;
    GRANT USAGE ON SCHEMA PROD.PUBLIC TO ROLE SEQUENZY_READ;
    GRANT SELECT ON ALL TABLES IN SCHEMA PROD.PUBLIC TO ROLE SEQUENZY_READ;
    GRANT SELECT ON ALL VIEWS IN SCHEMA PROD.PUBLIC TO ROLE SEQUENZY_READ;

    CREATE USER SEQUENZY_READER TYPE = SERVICE DEFAULT_ROLE = SEQUENZY_READ
      RSA_PUBLIC_KEY = 'MIIBIjANBgkq...';  -- contents of sequenzy_key.pub without the header lines
    GRANT ROLE SEQUENZY_READ TO USER SEQUENZY_READER;
    ```

    Then connect with your account identifier (for example `myorg-myaccount`), the user, warehouse, database, and the contents of `sequenzy_key.p8`.
  </Tab>

  <Tab title="BigQuery">
    Create a service account, then grant it:

    * **BigQuery Job User** on the project that runs the queries.
    * **BigQuery Data Viewer** on the datasets you query.

    Create a JSON key for the service account and paste its contents when you connect. Sequenzy shows the service account email on the connection so you can see whom to grant.
  </Tab>

  <Tab title="Redshift and Postgres">
    ```sql theme={null}
    CREATE USER sequenzy_reader PASSWORD '...';
    GRANT USAGE ON SCHEMA analytics TO sequenzy_reader;
    GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO sequenzy_reader;
    ```

    The database must accept TLS connections from the public internet. Use `verify-full` to also check the server certificate. Private networks, SSH tunnels and IP allowlists are not supported yet.
  </Tab>
</Tabs>

When you save a connection, Sequenzy runs a test query. If it fails, nothing is saved and you see the warehouse's reason. Credentials are stored encrypted and never shown again.

## 2. Write the query

A sync runs one `SELECT` (or `WITH ... SELECT`) statement. Sequenzy wraps it as a subquery, so any query your warehouse accepts works, including joins and views. The query cannot contain semicolons, even inside strings; use `CHR(59)` (`CODE_POINTS_TO_STRING([59])` on BigQuery) if you need to match one:

```sql theme={null}
select
  u.email,
  u.id as user_id,
  u.first_name,
  a.plan,
  a.mrr,
  u.updated_at
from analytics.users u
join analytics.accounts a on a.id = u.account_id
where u.email is not null
```

Use **Preview** to run the query with a 20-row limit and see its columns before you map them. Column names are matched exactly as your warehouse returns them; Snowflake returns unquoted names in upper case.

## 3. Map columns

<Tabs>
  <Tab title="Subscribers">
    | Field | What it does |
    | - | - |
    | Email | Required (or a phone column for SMS-only contacts). Identifies the contact. |
    | External ID | Your own user ID, stored on the contact. |
    | First name, last name | Contact names. |
    | Tags | An array column, or a comma-separated string. Tags are added, never removed. |
    | Custom attributes | Any other columns, each with an attribute name. Numbers, booleans, dates and arrays keep their types. |

    Contacts go through the same pipeline as [imports](/api-reference/subscribers/import-create), with suppression checks and risk review. For existing contacts, mapped values replace the stored ones, empty values never clear what is already there, and unsubscribed contacts are never resubscribed. Contacts the sync creates join the lists you choose; existing contacts keep their list memberships.

    Imports run as the person who last saved the sync. If that person leaves the workspace or can no longer edit contacts, runs stop with a message; open the sync and save it to continue as you.
  </Tab>

  <Tab title="Events">
    | Field | What it does |
    | - | - |
    | Contact email or external ID | Finds the contact. Events for contacts that do not exist yet are skipped. With a cursor column they are read again only when the row changes or on a full resync, so sync contacts first. |
    | Event name | A column, or one fixed name for every row. |
    | Event ID | A stable ID per event, such as an order ID. When omitted, one is derived from the row. An event is recorded once per ID: later changes to its properties are not applied. |
    | Occurred at | When the event happened. Defaults to the time of the sync. |
    | Properties | Any other columns, each with a property name. |

    Events are deduplicated by ID, so re-reading a row never records it twice. Events more than an hour old are stored as history: they count in segments and reports but never start automations.
  </Tab>
</Tabs>

## 4. Choose how it runs

* **Cursor column.** Pick a column that grows whenever a row changes, such as `updated_at`. Each run then reads only rows at or after the last value it saw, which keeps large tables cheap. Rows with an empty cursor are not read. Without a cursor, every run reads the whole result (up to 2,000,000 rows) and skips unchanged rows. Your warehouse bills each run as it bills any query; on BigQuery, a cursor on a partitioned or clustered column keeps the bytes scanned small.
* **Schedule.** Every 15 minutes, hourly, daily, weekly, or manual only. The first run starts when you create the sync.
* **Start automations.** Off by default. When on, new contacts can enter sequences that start on contact creation, and events from the last hour can start or advance sequences.
* **Double opt-in.** Off by default, for warehouses that only hold contacts who already agreed to receive email. When on, new contacts get a confirmation email first.

## How runs work

* **Only changes are sent.** Sequenzy keeps a fingerprint of every row it sent. Unchanged rows are counted as unchanged and skipped. A row is only recorded as sent after the import or event write accepted it. Rows that failed inside Sequenzy are read again on the next run; a row that keeps failing where the last run stopped is skipped with a note so the sync can move on. If an import later loses part of its rows, the next run sends every row again, up to three times a day.
* **Long backfills continue on their own.** With a cursor, a run stops after 30 minutes and the next one starts right away from where it stopped. Without a cursor, the rest waits for the next scheduled run. More than 2,000,000 rows with the same cursor value stop the sync with an error; use a more precise cursor column.
* **Edits apply right away.** Pausing, deleting or changing a sync stops a run in progress at its next page of rows; a changed sync then runs again with the new settings.
* **Progress.** The dashboard, `sequenzy warehouse syncs runs` and the API show rows read, synced, unchanged, skipped and failed, sample problems with their row numbers, and the progress of the contact imports each run queued.
* **Failures retry.** When a run fails, for example because of an expired key or a SQL error, the sync shows the reason and retries with backoff. Fix the problem and choose **Run now** to retry immediately.
* **Full resync.** Forgets what was sent and the cursor position, then sends every row again. Use it after changing data without touching the cursor column.

## Permissions

Only owners and admins can manage warehouse syncs in the dashboard. API keys need:

| Action | Scopes |
| - | - |
| View connections, syncs and runs | `warehouse:read` |
| Create, change, test or preview connections | `warehouse:write` |
| Create, change or run syncs | `warehouse:write` and `subscribers:write` |
| Event syncs | also `events:write` |
| Start automations or send double opt-in emails | also `automations:trigger` |
| Change the query, mapping, cursor or lists of a sync that starts automations or sends double opt-in emails, or fully resync it | also `automations:trigger` |
| Delete connections or syncs | `warehouse:delete` |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.