Infrastructure and storage

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

Long aisle of a dark data center

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.

Bundles of blue network cables

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;
Server racks with colored cabling

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.