Importing a CSV is how you get a lot of [contacts](/audience/contacts) into SendOps at once — a first load from another tool, or a periodic refresh of [attributes](/audience/attributes) you keep in your own system.

An import is an **upsert**, not an insert. A row whose email already exists **updates** that contact rather than creating a duplicate, so re-uploading a corrected file is safe and running the same import twice does nothing the second time.

## Running an import

From **Infrastructure › Contacts**, choose **Import CSV**, pick your file, and confirm. SendOps shows a preview of the parsed columns before anything is written.

A single import handles up to **100,000 rows**.



You can also upload a CSV of addresses from a [List's](/audience/lists) **Add contacts** dialog. That path only reads email addresses — use the Contacts page for a file that carries attributes or consent.

## The column format

One column is required. Everything else is optional, and any column SendOps doesn't recognise becomes a custom attribute.

| Column | Meaning |
|---|---|
| `email` | **Required.** The contact's address. A row with a blank or malformed address is rejected. |
| `unsubscribe_all` | Optional. The global unsubscribe flag — see [below](#a-blank-cell-changes-nothing), because a blank cell here is meaningful. Accepts `true`/`false`, `yes`/`no`, `y`/`n`, or `1`/`0`. |
| `topic:{name}` | Optional, one column per [topic](/audience/topics). The cell must read `OPT_IN` or `OPT_OUT`. For a topic called *Product updates*, the header is `topic:Product updates`. |
| *anything else* | A custom [attribute](/audience/attributes), with the column header as the attribute name. |

Recognised headers (`email`, `unsubscribe_all`, and the `topic:` prefix) are matched **case-insensitively**. Topic names and attribute names keep the case you write them in, so `topic:Product updates` and an attribute column `plan_tier` arrive exactly as spelled.

A small example — two attributes, one topic, and no consent column at all:

```csv
email,plan,last_signin_at,topic:Product updates
jane@example.com,pro,2026-07-28,OPT_IN
sam@example.com,free,2026-06-02,OPT_OUT
```


  The **Export** button on the Contacts page writes the same shape it reads — `email`, `unsubscribe_all`, `created_at`, `updated_at`, then a column per topic and per attribute. Exporting, editing, and re-importing is a supported round trip. Note that an export *does* carry explicit `unsubscribe_all` values, so re-importing one applies exactly the consent state it captured.


## A blank cell changes nothing

This is the part worth reading carefully, because it's about consent.

**Blank means "leave it alone."** For every optional column, an empty cell — or a column you simply didn't include — means SendOps makes **no change** to that field on an existing contact:

- No `unsubscribe_all` column, or an empty cell in one → the contact's global unsubscribe is **untouched**.
- An empty `topic:{name}` cell → that topic preference is untouched.
- An empty attribute cell → that attribute is untouched.

So a spreadsheet that only refreshes attributes updates only attributes. It cannot resurrect an unsubscribe, and it cannot quietly opt anyone back in.

Changing someone's global unsubscribe requires you to say so explicitly, by putting `true` or `false` in the cell. Re-subscribing a person who opted out is a deliberate act, so it takes a deliberate `false`.


  Before that date, an import whose file had **no** `unsubscribe_all` column treated every row as `false` and **cleared the global unsubscribe** on every contact in the file — up to 100,000 people, reported as a clean success. An ordinary attribute refresh could re-subscribe your entire audience.

  That's fixed, and no action is needed on your part. But if you ran an attribute-only import before 31 July 2026 and your unsubscribe counts look wrong, that's the likely cause — check the [Contacts](/audience/contacts) page filtered by global unsubscribe against your own records.


Adding someone to a [List](/audience/lists) has never been a consent change either, and isn't one now: dropping a globally-unsubscribed address into a List leaves them unsubscribed.

## What happens to bad rows

A problem in one row fails **that row only** — the rest of the file still imports. After the run you get a per-row report naming what was rejected and why. Rows are rejected for a missing or malformed `email`, an `unsubscribe_all` cell that isn't a recognised true/false value, or a `topic:` cell that isn't `OPT_IN` or `OPT_OUT`.

If the **same email appears more than once** in a file, the last occurrence wins and the import warns you. The whole file is refused only if it can't be read at all, has no `email` column, or exceeds the 100,000-row cap.

## After the import

Imported contacts are usable in SendOps **immediately** — they show up in the Contacts browser, count towards [Segments](/audience/segments), and can be sent to.

Mirroring them into your AWS account's SES contact list happens in the background, because AWS caps contact writes at roughly **one per second per account**. A large first-time import can therefore take a while to appear fully in the AWS console even though it's complete in SendOps. Nothing waits on it — see [where contacts actually live](/audience/contacts#where-contacts-come-from).

## What's next?

- [Contacts](/audience/contacts) — what a contact is and where it lives.
- [Attributes](/audience/attributes) — registering the fields your columns write into.
- [Topics](/audience/topics) — the consent streams `topic:` columns set.
- [Syncing external state](/audience/syncing-external-state) — keeping contacts current from your own system, instead of by file.
- [Contacts API](https://developers.sendops.dev/api-reference/contacts) — the programmatic equivalent, including bulk import.