Do Not Trust omitempty When Exposing a JSON-TEXT Column
A nullable JSON-TEXT column is not covered by omitempty alone: omitempty only knows Go emptiness, not SQL NULL. The story of a four-layer exposure.
TL;DR
A public detail page wasn't showing saved additional info even though the data was in the database. The fix required passing a nullable JSON column through four layers, and realizing Go's omitempty only drops nil, not empty strings. Normalizing null values to nil in the mapper and adding a fallback on the frontend finally hid the empty field correctly.
A QA report landed that morning about the entity detail module. An entity I had saved with its "Info Tambahan" section filled in never showed up on the public detail page. The data was clearly sitting in the database. I opened the code thinking it would be simple. Just add a field to the DTO, slap on an omitempty tag, done.
It was not that simple. A nullable JSON-TEXT column has to cross four layers before a client can safely consume it. And in one of those layers, my assumption about how Go handles empty data turned out to be completely wrong.
The first guess that missed
The first thing I did was textbook. I added a new field to the database model, then passed it through to the DTO.
type EntityDTO struct {
// other fields...
Attributes json.RawMessage `json:"attributes,omitempty"`
}The logic looked perfect. Data present, it shows up. No data, the omitempty tag takes care of the rest and the field stays out of the JSON response. I deployed, opened the detail page, and... still empty. Worse, it sometimes showed up as an empty string that confused the frontend parser.
That is when I realized my guess about omitempty had been too optimistic. Databases and programming languages simply define "empty" differently.
The four layers of the data journey
To fix it, I had to re-map how data travels from the database to the HTTP response. Four points had to be locked down.
First, the model layer. The column is defined as sql.NullString with the db:"attributes" tag. This type is the official scan destination for columns that can be NULL [1]. It carries two properties: String and Valid.
Second, the repository layer. The SELECT query must explicitly name the attributes column. Forget it, and the data never gets fetched, no matter how correct the DTO is.
Third, the DTO layer. I used json.RawMessage. I preferred this pass-through over unmarshalling on the backend. The reason is simple: the JSON the editor stored is the JSON the client should receive, with no risk of key re-ordering or losing the original format to a needless re-encode. The type exists to defer decoding or use a precomputed encoding [2].
Fourth, and the decisive one, is the mapper layer. That is where the real fix lives.
Bridging SQL emptiness and Go emptiness
The core problem is that Go's omitempty only recognizes Go emptiness: false, 0, nil pointers, nil interfaces, and zero-length values (arrays, slices, maps, strings) [2].
In SQL land, emptiness has three faces: NULL, the empty string '', or the literal string 'null'.
When the database returns NULL, sql.NullString sets Valid to false. But converting it raw into a json.RawMessage can leave a RawMessage holding an empty string. That empty string is not empty to omitempty because it is not a nil pointer. So the field still shows up in the API response with a useless value.
The fix is explicit normalization in the mapper. I wrote a small function called rawJSONObject that translates SQL emptiness into Go emptiness.
// rawJSONObject: JSON column NullString -> RawMessage; empty/null/"null" -> nil
// so omitempty drops the field (JSON object passed verbatim to response).
func rawJSONObject(ns sql.NullString) json.RawMessage {
if !ns.Valid || ns.String == "" || ns.String == "null" {
return nil
}
return json.RawMessage(ns.String)
}With this logic, whenever the database returns any of the three faces of emptiness, the mapper returns nil. And only with nil does the DTO's omitempty tag actually work: removing the field from the JSON response entirely.
A backend fix is not complete without making sure the frontend handles it gracefully. On the client, never assume the field will be there.
The frontend implementation uses Object.entries(), which returns an array of a given object's own string-keyed property key-value pairs [3].
const additionalInfo = Object.entries(entity.attributes ?? {});
// render into ContactCard as a dl listNote the ?? {} there. That is the safety net. If the server sends null or the field is gone entirely thanks to omitempty, additionalInfo stays an empty array instead of throwing.
On the docs side, the OpenAPI schema defines the field as {type: object, additionalProperties: true}. Per the spec, additionalProperties can be a boolean or an object, and consistent with JSON Schema its default is true [4]. The frontend can type it as Record<string, unknown> without fearing a strict type mismatch.
So next time you expose a JSON-TEXT column to a public endpoint, do not stop at the DTO. The key is in the mapper: make sure sql.NullString gets translated to nil for all three faces of SQL emptiness. It is the only way omitempty truly drops an empty field instead of shipping a misleading empty string.