Convert between structs

Go requires manual field mapping or reflection to convert between structs like tar.Header and zip.FileHeader.

When types don't line up

You just finished parsing a tar archive. You have a tar.Header full of file metadata. Now you need to hand that metadata to a zip writer, which expects a zip.FileHeader. The fields are almost identical. The names match. The logic is straightforward. You reach for a cast or a built-in conversion function. Go gives you nothing.

Go does not convert between structs automatically. You must map the fields yourself. This feels like extra typing at first. It is a deliberate design choice. Go treats structs as explicit memory layouts, not as bags of loosely related data. The language refuses to guess which fields should line up, which ones should be dropped, or how to translate mismatched types. You write the mapping. You own the translation.

Why Go refuses to guess

Think of a struct like a customs declaration form. Each box has a specific purpose and a strict format. If you need to fill out a different form for a different country, you cannot just photocopy the first one and hope the boxes align. You have to read each line, translate the units, and decide what to leave blank. Go forces you to do the same with data structures.

Automatic conversion hides decisions. If the compiler silently mapped src.Name to dst.Title, you would never notice when the source struct gains a Description field that should have gone to dst.Summary. Explicit mapping makes every data flow visible. It turns structural changes into compile-time errors instead of runtime surprises. Go also relies heavily on zero values. When you create a struct without assigning every field, the unassigned fields default to their type zero value. The compiler will not warn you if you forget a field. It trusts you to know what belongs in the destination.

Explicit mapping is a feature, not a tax.

The minimal mapping

You create a new struct and assign each field from the source. The compiler verifies every assignment. If a field type differs, you convert it manually. If a field does not exist in the destination, you leave it out. The destination struct fills the gaps with zero values.

// ConvertTarToZip translates tar archive metadata into zip file metadata.
func ConvertTarToZip(src tar.Header) zip.FileHeader {
    // Return a new struct literal to avoid mutating the source.
    return zip.FileHeader{
        // Direct field copy when names and types match exactly.
        Name:     src.Name,
        // Time types align, so no conversion is necessary.
        Modified: src.ModTime,
        // Explicit type conversion bridges int64 to fs.FileMode.
        Mode:     fs.FileMode(src.Mode),
        // Cast handles the signed to unsigned integer transition.
        UncompressedSize64: uint64(src.Size),
    }
}

The function returns a freshly allocated zip.FileHeader. Each field assignment is checked at compile time. src.Mode is an int64, but zip.FileHeader expects an fs.FileMode. The explicit type conversion fs.FileMode(src.Mode) tells the compiler you understand the translation. src.Size is an int64, but the zip package wants a uint64. The uint64() conversion handles the sign change. If you skip the conversion, the compiler rejects the code with cannot use src.Mode (variable of type int64) as fs.FileMode value in field value.

What happens under the hood

When the compiler sees a struct literal, it allocates space for the new value. The location depends on escape analysis. If the struct stays within the function, it lives on the stack. If you return a pointer to it or store it in a slice, it moves to the heap. The compiler then copies each field from the source expression into the destination memory layout. No reflection runs. No hidden loops execute. The generated machine code is a series of direct memory copies and type conversions. This is why manual mapping is fast. It compiles down to the same instructions you would write in C.

Go's type system enforces structural integrity at compile time. If you rename a field in the source struct, the mapping function breaks immediately. You fix it before the code ships. This breaks the fragile base class problem that plagues languages with implicit inheritance or automatic casting. You control the contract between types. The compiler guarantees that the destination struct receives exactly what you told it to receive.

Boilerplate is cheap. Hidden bugs are expensive.

A realistic data pipeline

Real applications rarely convert between two identical structs. You usually bridge a database model, an internal domain type, and an API response. The fields diverge. Some get renamed. Some get computed. Some get dropped entirely. You also need to handle cases where the conversion itself can fail.

// UserDB represents a row from the users table.
type UserDB struct {
    ID        int64
    Username  string
    Email     string
    CreatedAt time.Time
    IsActive  bool
}

// UserAPI represents the JSON response sent to clients.
type UserAPI struct {
    UserID   int    `json:"id"`
    Name     string `json:"name"`
    Joined   string `json:"joined"`
    Status   string `json:"status"`
}

// ToAPI converts a database user row into a public API response.
func ToAPI(u UserDB) (UserAPI, error) {
    // Validate that the ID fits in a 32-bit integer before narrowing.
    if u.ID > math.MaxInt32 || u.ID < math.MinInt32 {
        return UserAPI{}, fmt.Errorf("user ID %d exceeds int32 range", u.ID)
    }

    // Map boolean state to a human-readable string for the client.
    status := "inactive"
    if u.IsActive {
        status = "active"
    }

    // Return the mapped struct and nil error on success.
    return UserAPI{
        UserID: int(u.ID),
        Name:   u.Username,
        Joined: u.CreatedAt.Format("2006-01-02"),
        Status: status,
    }, nil
}

The mapping function handles type narrowing, string formatting, and boolean translation. It also omits the Email field entirely because the API should not expose it. Every transformation is visible in the function body. If the database schema changes tomorrow, the compiler points exactly to the line that needs updating. Go developers accept the if err != nil boilerplate because it makes the unhappy path visible. You never skip error handling by accident.

The mapping function is the single source of truth.

Method receivers versus standalone functions

You can attach the conversion logic directly to the source type using a method receiver. This keeps related behavior grouped together. The convention is to use a short receiver name that matches the type, usually one or two letters.

// ToAPI converts the database row into a public API response.
func (u UserDB) ToAPI() (UserAPI, error) {
    // Receiver u gives direct access to all exported fields.
    if u.ID > math.MaxInt32 || u.ID < math.MinInt32 {
        return UserAPI{}, fmt.Errorf("user ID %d exceeds int32 range", u.ID)
    }

    status := "inactive"
    if u.IsActive {
        status = "active"
    }

    // Return the mapped struct and nil error on success.
    return UserAPI{
        UserID: int(u.ID),
        Name:   u.Username,
        Joined: u.CreatedAt.Format("2006-01-02"),
        Status: status,
    }, nil
}

Callers use user.ToAPI() instead of ToAPI(user). This reads naturally when the conversion is tightly coupled to the source type. Standalone functions work better when the conversion requires multiple inputs or when you want to keep the source type free of behavioral methods. Both patterns compile to identical machine code. Pick the one that matches your package boundaries.

Trust the compiler to keep the mapping honest.

Where things go wrong

The most common mistake is assuming a field maps automatically. Go does not infer missing fields. If you forget to assign Joined, the UserAPI struct gets the zero value for time.Time, which formats to an empty string or a default timestamp depending on how you read it. The compiler will not warn you about unused source fields. It only checks the fields you explicitly assign.

Unexported fields create another trap. If UserDB has a passwordHash string field, you cannot access it from a different package. The compiler rejects the code with u.passwordHash undefined (cannot refer to unexported field or method passwordHash). You must either export the field, provide a getter method, or perform the conversion in the same package where the struct is defined. Public names start with a capital letter. Private names start lowercase. There are no public or private keywords.

Some developers reach for reflection to avoid writing mapping functions. The reflect package can iterate over struct fields and copy values dynamically. It works, but it runs at runtime, bypasses type checking, and panics on type mismatches. It also cannot access unexported fields without extra tricks. Reflection is useful for generic serialization libraries, but it is the wrong tool for application data mapping. You lose compile-time safety and pay a performance penalty for every conversion.

The compiler catches type mismatches. It will not catch missing fields.

Choosing your mapping strategy

Use manual field mapping when the structs are small and the transformation logic is straightforward. Write the assignment explicitly. It compiles fast, runs fast, and fails fast when types change.

Use code generation when you have dozens of nearly identical structs and the mapping is purely mechanical. Tools like go generate with custom templates or libraries like genny can produce the boilerplate at build time. The generated code still compiles to direct assignments, so you keep the performance and type safety without typing every line.

Use an interface when multiple types need to satisfy the same contract instead of converting between concrete structs. Define the behavior you need, implement it on each type, and pass the interface around. You avoid copying data entirely and let polymorphism handle the variation.

Use reflection only when building generic utilities that must operate on unknown types at runtime. Accept the performance cost and the loss of compile-time guarantees. Do not use it for business logic or data transformation pipelines.

Write the mapping once. Trust the compiler to keep it honest.

Where to go next