TLDR: Airtable's API stores single-select values as JSON arrays. Migrate them "as-is" and every downstream caller that expects a bare string will blow up. Separately: when your sort key lives in a linked record, skip the Postgres gymnastics and sort it in JavaScript.

The Background

I'm building a custom practice management app for a law firm client, a small law firm, to replace their Airtable setup. We migrated all the historical data over with scripts/migrate-airtable and shipped it fast.

One afternoon in early July, two unrelated Airtable gotchas surfaced back-to-back. Different traps. Different fixes. Both worth knowing.

Trap 1: The Bracket Bomb

my business partner sent a Loom (a screen recording) of a matter timeline with a big red banner: "Email sync: Google API error (400)."

My first instinct was a credential issue. Nope.

I replayed the exact failing call with a read-only service script and got back: 400 Invalid label: ["Label_683"].

Those brackets. That's the tell.

Airtable stores its devGmailLabelID field as a multivalue field — so even though there's only ONE Gmail label per matter, the API serializes it as ["Label_683"]. A JSON-array string. Our migration script, to its eternal shame, had a comment in PLAN.md:101 that literally said: "copied as-is."

So Supabase (my Postgres host) had the string ["Label_683"] sitting in a column that Gmail's messages.list expected to receive as the bare value Label_683.

13 of 44 labeled matters were broken. Every single one was a legacy Airtable import. Matters created natively in the app — which wrote the bare id directly — were totally fine. That split is what nailed it. When legacy rows fail and new rows don't, suspect the migration, not the live code.

Fix was one SQL migration:

update matters
set gmail_label_id = gmail_label_id::jsonb ->> 0
where gmail_label_id like '[%';

Unwrap, take element zero. Done. Filed under "never copy Airtable multivalue fields as-is."

Trap 2: The Unruly Sort

Same afternoon, I added clickable sort headers to the matter list. Most columns were fine — sort by date, sort by status, no drama. But sort by client name was awkward.

Client name doesn't live on the matters row. It's in a linked contact record. I couldn't use a generated column (those can only see same-row values). So I drafted a Postgres VIEW with a correlated subquery to expose client_sort_name.

My Opus reviewer (an AI review pass — GENIUS feature, runs before I ship) killed it immediately: the view collided with the existing .in("id", …) pre-query whenever sort and search ran at the same time. And even if I fixed that, it was over-engineered — we're sorting roughly 200 rows at a single small firm.

The real answer was embarrassingly simple: sort in JavaScript.

When sort.key === "client", skip the database pagination, resolve each matter's first Client contact name from the contacts object already loaded in memory, and call Array.sort(). No extra DB round-trip. No view. No collision. Easy to read in six months.

The Two Lessons, Side by Side

They're different traps with different fixes — worth keeping separate in your head:

  1. Multivalue fields → unwrap at migration time. Never copy ["value"] raw into a scalar column. The symptom is a downstream 400 where the error value has literal brackets in it.
  2. Cross-table sort key → sort app-side. If Postgres can't see the sort column on the same row, and the data is already in memory, Array.sort() is the answer. The database gymnastics aren't worth it at small scale.

One afternoon, two burned hours, two clean fixes. Hopefully this saves you one of them.