Question

Lowercase JSON Key Names with json.Marshal in Go

gojsonserializationtagsmarshalling

Question

I want to use Go's encoding/json package to marshal a struct that is declared in an imported package.

For example:

type T struct {
    Foo int
}

Because the struct comes from another package, its exported fields begin with uppercase letters. If I marshal it like this:

out, err := json.Marshal(&T{Foo: 42})

the result is:

{"Foo":42}

However, I want the JSON output to use lowercase key names instead:

{"foo":42}

Is there an easy way to do this when using encoding/json in Go?

Short Answer

By the end of this page, you will understand how Go's encoding/json package chooses JSON field names, why exported struct fields appear with uppercase names by default, and how to produce lowercase keys using struct tags, wrapper types, or custom marshaling patterns.

Concept

Go's encoding/json package converts struct fields into JSON object keys. By default, it uses the exact exported field name as the JSON key.

That means this struct:

type T struct {
    Foo int
}

becomes:

{"Foo":42}

Why this happens

In Go, only exported fields are visible to packages like encoding/json. Exported names start with an uppercase letter.

So there are two separate rules at work:

  • Go visibility rule: fields must be exported to be accessible to encoding/json
  • JSON naming rule: if no tag is provided, the field name itself becomes the JSON key

The normal solution: struct tags

If you control the struct definition, the standard solution is to add a JSON tag:

type T struct {
    Foo int `json:"foo"`
}

Now the JSON output becomes:

{"foo":42}

But what if the struct is from an imported package?

If the type is declared in another package and you cannot modify it, you usually cannot directly change its JSON field names using tags on the original type.

Instead, common solutions are:

  • create your own local struct for JSON output
  • create a wrapper struct
  • implement custom JSON marshaling in your own type
  • convert the data into a map[string]any when appropriate

This matters in real programming because API responses often need a specific JSON format that does not match your internal Go types exactly.

Mental Model

Think of a Go struct as a form with labeled fields.

  • The Go field name is the internal label, like Foo
  • The JSON tag is a custom external label, like foo

Without a tag, encoding/json prints the label exactly as written.

So if the field says Foo, the JSON key will be Foo.

A JSON tag is like putting a sticker over the original label so the outside world sees foo instead.

If the form belongs to another package and you cannot place your own sticker on it, you make a copy of the data into a new form that has the labels you want.

Take Quiz

Syntax and Examples

The basic syntax for controlling JSON key names in Go is a struct tag.

Struct tag syntax

type T struct {
    Foo int `json:"foo"`
}

Example: lowercase JSON key

package main

import (
    "encoding/json"
    "fmt"
)

type T struct {
    Foo int `json:"foo"`
}

func main() {
    out, err := json.Marshal(T{Foo: 42})
    if err != nil {
        panic(err)
    }

    fmt.Println(string(out))
}

Output:

{"foo":42}

What changed?

The field is still named Foo in Go, so it remains exported and usable by encoding/json. But the JSON tag tells the encoder to use foo as the output key.

If you cannot edit the imported type

Suppose an imported package defines:

package other

type T struct {
    Foo int
}

You can create your own response struct:

package main

import (
    "encoding/json"
    "fmt"
)

type ExternalT struct {
    Foo int
}

type TResponse struct {
    Foo int `json:"foo"`
}

func main() {
    src := ExternalT{Foo: 42}

    resp := TResponse{
        Foo: src.Foo,
    }

    out, err := json.Marshal(resp)
    if err != nil {
        panic(err)
    }

    fmt.Println(string(out))
}

Output:

{"foo":42}

This is the most common and safest approach when working with imported structs.

Step by Step Execution

Consider this example:

package main

import (
    "encoding/json"
    "fmt"
)

type T struct {
    Foo int `json:"foo"`
}

func main() {
    value := T{Foo: 42}
    out, err := json.Marshal(value)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(out))
}

Step-by-step

1. Define the struct

type T struct {
    Foo int `json:"foo"`
}
  • The Go field name is Foo
  • It is exported because it starts with an uppercase letter
  • The JSON tag says to encode it as foo

2. Create a value

value := T{Foo: 42}

A struct value is created with Foo set to 42.

3. Marshal to JSON

out, err := json.Marshal(value)

encoding/json inspects the struct fields.

  • It sees exported field Foo
  • It checks for a json tag
  • It finds json:"foo"
  • It uses foo as the JSON key

4. Print the result

fmt.Println(string(out))

The output is:

{"foo":42}

What if there were no tag?

If the struct were:

type T struct {
    Foo int
}

then json.Marshal(T{Foo: 42}) would produce:

{"Foo":42}

because the default key name is the field name itself.

Real World Use Cases

Lowercase JSON keys are common in real applications because many APIs follow JSON naming conventions such as camelCase or snake_case.

Common scenarios

  • REST API responses
    • Your Go struct might use UserID, but your API should return userId
  • Third-party API integration
    • An external service may require exact field names like access_token
  • Frontend compatibility
    • JavaScript clients often expect lowercase or camelCase keys
  • Data transformation layers
    • Internal domain models may not match public JSON contracts
  • Backward compatibility
    • You may need to preserve an existing JSON schema even if Go field names change

Example: API response model

type User struct {
    ID   int
    Name string
}

type UserResponse struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

Even if your internal model and response model contain the same data, keeping a separate response struct gives you precise control over the API shape.

Take Quiz

Real Codebase Usage

In real projects, developers rarely expose imported structs directly as JSON if the output format matters.

Common patterns

1. Separate API/DTO structs

A DTO (Data Transfer Object) is a struct designed specifically for input or output.

type Product struct {
    SKU   string
    Price int
}

type ProductResponse struct {
    SKU   string `json:"sku"`
    Price int    `json:"price"`
}

This pattern is clean and explicit.

2. Mapping from domain models to response models

func ToProductResponse(p Product) ProductResponse {
    return ProductResponse{
        SKU:   p.SKU,
        Price: p.Price,
    }
}

This is common in handlers and service layers.

3. Wrapper structs around imported types

If you need only part of an imported value:

type PublicT struct {
    Foo int `json:"foo"`
}

Then copy the values you want to expose.

4. Custom MarshalJSON

If you need special formatting logic, you can implement:

func (t MyType) MarshalJSON() ([]byte, error) {
    type alias struct {
        Foo int `json:"foo"`
    }
    return json.Marshal(alias{Foo: t.Foo})
}

This is useful when output needs transformation, conditional fields, or compatibility behavior.

5. Validation and shaping before marshaling

Real code often validates or filters data before returning JSON.

  • omit empty fields with omitempty
  • rename fields with tags
  • avoid exposing internal fields accidentally
  • keep transport models stable even if internal structs change

Common Mistakes

1. Assuming encoding/json automatically lowercases field names

Broken expectation:

type T struct {
    Foo int
}

Many beginners expect this to become:

{"foo":42}

But it actually becomes:

{"Foo":42}

Fix

Use a struct tag:

type T struct {
    Foo int `json:"foo"`
}

2. Trying to use an unexported field

Broken code:

type T struct {
    foo int `json:"foo"`
}

This field is unexported, so encoding/json will ignore it.

Fix

Make the field exported, then use a tag:

type T struct {
    Foo int `json:"foo"`
}

3. Forgetting that imported types cannot be modified

If a struct comes from another package and has no JSON tags, you usually cannot change its marshaled field names directly.

Fix

Create your own struct:

type TResponse struct {
    Foo int `json:"foo"`
}

Then copy the data.

4. Exposing internal structs directly in APIs

This makes your JSON output tightly coupled to internal code.

Fix

Use dedicated request/response structs for stable API contracts.

5. Using map[string]any for everything

This works, but it loses type safety.

m := map[string]any{
    "foo": 42,
}

Better approach

Prefer structs with tags when the shape is known ahead of time.

6. Writing incorrect tag syntax

Broken code:

type T struct {
    Foo int `json:foo`
}

Fix

Tags must be quoted correctly:

type T struct {
    Foo int `json:"foo"`
}

Comparisons

Here is how the common approaches compare.

ApproachWorks with imported type?Type-safeBest forNotes
Struct tag on original typeNo, unless you control the typeYesYour own structsStandard and simplest solution
Separate response structYesYesAPIs and clean architectureMost common real-world pattern
Wrapper type with custom MarshalJSONYesYesSpecial formatting logicMore flexible, more code
map[string]anyYesNoQuick dynamic outputEasy, but less maintainable

Struct tags vs custom marshaling

OptionWhen to use
Struct tagsWhen you only need to rename fields or add simple options like omitempty
MarshalJSONWhen you need computed fields, custom formatting, conditional output, or control over an imported type through a wrapper

Imported struct vs local DTO

ChoiceAdvantageTradeoff
Marshal imported struct directlyLess codeYou cannot easily control JSON shape
Convert to local DTOFull control over field names and outputRequires a conversion step
Take Quiz

Cheat Sheet

Quick rules

  • encoding/json only marshals exported struct fields
  • Exported Go fields start with an uppercase letter
  • By default, JSON keys use the Go field name exactly
  • To rename a key, use a struct tag
  • If the type is from another package and cannot be edited, create your own output struct or wrapper

Basic syntax

type T struct {
    Foo int `json:"foo"`
}

Default behavior

type T struct {
    Foo int
}

Output:

{"Foo":42}

Renamed field

type T struct {
    Foo int `json:"foo"`
}

Output:

{"foo":42}

Ignore a field

type T struct {
    Foo int `json:"-"`
}

Omit empty values

type T struct {
    Foo int `json:"foo,omitempty"`
}

When the type is imported

Use one of these:

  • local response struct
  • wrapper type
  • custom MarshalJSON
  • map[string]any for dynamic cases

Remember

foo int `json:"foo"`

will not be marshaled because foo is unexported.

FAQ

Can Go automatically lowercase struct field names in JSON?

No. By default, encoding/json uses the exported field name exactly as written unless you add a JSON tag.

How do I make a JSON key lowercase in Go?

Use a struct tag like this:

Foo int `json:"foo"`

Can I change JSON tags on a struct from another package?

No. You cannot add tags to a type you do not control. Instead, create your own struct or wrapper type.

Why doesn't encoding/json use unexported fields?

Because unexported fields are not accessible outside their package through reflection in the way encoding/json needs.

What is the best way to control JSON output for imported structs?

Usually, create a separate response/DTO struct with the exact JSON tags you want and copy the needed fields.

Should I use a map instead of a struct for custom JSON keys?

Only if the JSON shape is dynamic. If the structure is known, structs are safer and easier to maintain.

When should I implement MarshalJSON?

Use it when tags are not enough, such as for computed values, conditional fields, or wrapping imported types with custom output rules.

Take Quiz

Mini Project

Description

Build a small JSON response adapter for a type that simulates coming from another package. This project demonstrates the real-world pattern of converting an internal or external struct into a dedicated API response struct with lowercase JSON keys.

Goal

Create a Go program that converts a source struct into a JSON-friendly response struct with lowercase field names.

Requirements

  1. Define a source struct with exported fields such as Foo and Bar.
  2. Define a separate response struct that uses JSON tags like json:"foo" and json:"bar".
  3. Copy data from the source struct into the response struct.
  4. Marshal the response struct to JSON and print it.
  5. Ensure the output keys are lowercase.
Take Quiz

Keep learning

Related questions

View more