The Setup
I've been building a native iOS CRM to track KOLs (Key Opinion Leaders, the expert partners who co-host webinars) for an ecommerce business.
Backend is Supabase (a hosted Postgres platform with a Swift SDK). Models are plain Swift structs that conform to Codable and get decoded straight from JSON query responses.
Pretty clean setup. Until it wasn't.
The Wall
Fields kept coming back nil.
Not all of them — specific ones. relationshipOwner. titleCredentials. availabilityNotes. The app would render blank where it should have data, and no error was thrown anywhere. The decode "succeeded." The struct existed. Just... hollow.
I checked RLS policies. I checked column names. I checked the query shape. All fine.
What I Tried First (and Made Worse)
I ran a full WIN audit on the model layer: every column that's nullable in Postgres became an Optional in Swift. That part was correct — a non-optional Swift field will crash the entire array decode if Postgres returns even one NULL.
But at the same time I added this to SupabaseService.swift, thinking "belt and suspenders":
db: .init(
decoder: {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}(),
encoder: {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}()
)
The logic felt bulletproof. Supabase returns snake_case keys. My Swift properties are camelCase. Let the decoder auto-convert them and let the CodingKeys back it up.
More fields went nil. Different fields this time.
The Actual Conflict
Here's what I missed.
KOL.swift already had explicit CodingKeys doing the mapping:
enum CodingKeys: String, CodingKey {
case relationshipOwner = "relationship_owner"
case titleCredentials = "title_credentials"
case availabilityNotes = "availability_notes"
// ...
}
When keyDecodingStrategy = .convertFromSnakeCase is active, Swift's decoder transforms the incoming JSON keys before it tries to match them. So relationship_owner becomes relationshipOwner in the decoder's key container.
Then it looks up relationshipOwner against your CodingKeys raw string values.
Your CodingKeys says: case relationshipOwner = "relationship_owner".
The raw value is "relationship_owner" — not "relationshipOwner". No match. Field decodes to nil.
And because I'd just made those fields Optional in the same commit… no throw. No error. Just silence.
The two fixes together created the perfect invisible bug: the optional-ization turned what would've been a loud keyNotFound crash into a completely quiet nil.
The Fix
Remove the decoder config. All of it.
// before: db: .init(decoder: ..., encoder: ...)
// after: nothing. just the auth init.
Explicit CodingKeys is the mapping. The decoder doesn't need to also transform the keys — they're already handled. Adding convertFromSnakeCase on top doesn't add safety, it overrides the map you already wrote.
Why This Matters
Pick one strategy and commit to it.
Either use keyDecodingStrategy = .convertFromSnakeCase and write your Swift properties in camelCase with no explicit CodingKeys — the decoder handles the translation automatically.
Or write explicit CodingKeys with raw snake_case strings and use a plain JSONDecoder() — your enum handles every mapping by hand.
Never both. They don't add up. They collide.
The part that's genuinely sneaky: if your fields are non-optional, this conflict throws and you find it immediately. But the moment you make them optional — which is the correct thing to do for nullable DB columns — the decoder silently drops the value and moves on. The bug hides behind the fix.