Infrastructure and storage
Sheets, database or CRM, and what serves as the source of truth.

Keep the whole market in one place outside your CRM, and send the CRM only the accounts someone is actually working. That place can be a Google Sheet for a long time. What matters is that one store is named the source of truth, that every row has a stable key, and that each field has a single system allowed to write it.
The usual failure is a CRM filled with thousands of accounts nobody has touched, next to three exported spreadsheets that disagree. Reps stop trusting all of it and go back to building their own lists.
Pick the store for your volume and your team
| Criterion | Spreadsheet | Database (Postgres, BigQuery) | CRM |
|---|---|---|---|
| Comfortable size | Up to a few tens of thousands of rows | Millions of rows | The accounts being worked |
| Who can use it | Everyone, right away | Whoever writes SQL, or through a tool on top | Sales, inside their daily tool |
| History of changes | Weak (version history only) | Full, if you design for it | By field, depending on the plan |
| Several automated writers | Risky | Built for it | Through its API, with limits |
| Scheduled jobs | Limited | Native | Depends on the product |
| Best role | The TAM of a small team | The TAM once it runs on code | The relationship with the account |
A Google spreadsheet accepts up to 10 million cells, but it gets slow well before that, especially with lookups across tabs. In practice, I’d stay in a sheet while one or two people maintain the list, and move when one of these becomes true:
- Two automated processes need to write at the same time.
- You want a history of what changed on each account.
- Formula recalculation is slowing people down.
- Signals have become a large table you query every week.
Moving early costs you more than moving late, since a database nobody on the team can query becomes a store nobody reads.
Give every field one owner
Most data conflicts come from two systems writing the same field. Decide once, and put the table in your documentation.
| Field | Written by | Read by |
|---|---|---|
| Domain, SIREN, company name | TAM | CRM, sending tool |
| Industry, headcount, location | TAM | CRM |
| Fit score, tier | TAM | CRM |
| Signals | TAM | CRM, reps |
| Account owner, deal stage, activity | CRM | TAM (read only) |
| Customer status | CRM | TAM, to exclude |
| Opt-outs | Whichever system receives them, copied at once to the suppression table | Every system before any send |
The rule when syncing: a blank value never overwrites a filled one, and only the owner of a field may change it.
A schema that holds up
Use the domain as the primary key, as set out in the deduplication chapter. For an account with no website, generate a key from the registry number, like siren-123456789.
CREATE TABLE companies (
domain text PRIMARY KEY,
siren char(9) UNIQUE,
name text NOT NULL,
naf text,
headcount_band text,
tier char(1),
fit_score int,
status text DEFAULT 'active',
crm_id text,
source text,
enriched_at date
);
CREATE TABLE contacts (
contact_id bigserial PRIMARY KEY,
domain text REFERENCES companies,
full_name text,
job_title text,
persona text,
linkedin_url text UNIQUE,
email text,
email_verified_at date,
source text,
enriched_at date
);
CREATE TABLE signals (
signal_id bigserial PRIMARY KEY,
account_domain text REFERENCES companies,
signal_type text NOT NULL,
signal_value text,
source text,
event_date date,
detected_at date,
used_in_campaign text
);
CREATE TABLE suppression (
match_value text PRIMARY KEY,
match_type text NOT NULL,
reason text,
added_at date NOT NULL
);
CREATE TABLE enrichment_log (
run_id bigserial PRIMARY KEY,
run_date date,
action text,
source text,
rows_sent int,
rows_filled int,
cost numeric
);
source and enriched_at are what make the table maintainable: they tell you what is stale and which provider produced a wrong value. enrichment_log gives you the real cost per account and the fill rate by source, which you’d otherwise have to reconstruct.
In a spreadsheet, use the same structure with one tab per table: companies, contacts, signals, suppression, log. Format every identifier as text. Number formatting drops leading zeros from codes and the plus sign from phone numbers. Keep formulas in a separate view tab, so a sort or an import can’t break them.
An upsert that respects the blank rule looks like this:
INSERT INTO companies (domain, siren, name, naf, headcount_band, source, enriched_at)
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_DATE)
ON CONFLICT (domain) DO UPDATE SET
siren = COALESCE(EXCLUDED.siren, companies.siren),
naf = COALESCE(EXCLUDED.naf, companies.naf),
headcount_band = COALESCE(EXCLUDED.headcount_band, companies.headcount_band),
source = EXCLUDED.source,
enriched_at = EXCLUDED.enriched_at;
Plan how data moves between systems
Three moves cover most setups.
| Move | When | How |
|---|---|---|
| Enrichment into the TAM | At build time, then on each refresh | Enrichment tool on the sheet, or a script calling an API |
| TAM to CRM | When an account reaches a tier that sales will work | Filtered import keyed on domain, then store the crm_id |
| CRM to TAM | Weekly | Export or API read of customer status, owner and open deals |
Start with imports and exports done by hand on a fixed day. It’s slower, but you’ll see the errors, and you’ll know exactly what to automate afterwards. Whatever you automate has to be one-way per field and has to log what it wrote.
Here is how this looks with Derrick (a tool I co-founded, so factor that in). Each surface fits a different stage. For a list worked in rows, the web app or the Google Sheets sidebar: you pick a feature, map a column, and the output lands in new columns. For a scheduled job, a back end or a no-code workflow in Zapier, Make or n8n, the API takes the same actions at the same credit cost. For a quick check from an AI assistant, there is the MCP server. The API and the MCP server start at the Plus plan.
Derrick does the enrichment, and your records live in the CRM or database you chose above. To move results to HubSpot or Salesforce, use an export and import, or a job written against the API. The method above works the same with any enrichment provider, or with a person filling in columns.
Protect what took months to build
A list you built by spending credits and hours deserves basic hygiene, and a few habits are enough.
| Risk | Habit |
|---|---|
| A bad sort or import scrambles the rows | Protect the key columns, and keep raw imports in their own tab |
| Accidental bulk overwrite | Weekly dated copy of the file, or a scheduled database dump |
| Everyone can see personal data | Restrict access by role, and share company-level views by default |
| Contact data spreads through exports | One location for contacts, and delete working copies once a campaign is done |
| Nobody knows what a column means | A data dictionary tab: field, definition, owner, refresh cadence |
Your next step: write the field ownership table for your own setup, name the source of truth in one sentence, and create the five tabs or tables above. Move your current list in with source and enriched_at filled, because keeping it up to date depends on those two columns.