Structs and Data Modeling

Updated

September 13, 2026

Structs and Data Modeling

A struct is a named bag of fields. The boring default is a few structs for the desk (ticket, order, shift), composite literals that name their fields, and embedding only when promotion is the point — not as a substitute for inheritance, which Go does not have.

Mental model

type Ticket struct { ... } defines a type. Values are copied on assignment. A field name that starts with an upper-case letter is exported (visible from another package). Lower-case is package-private.

A composite literal Ticket{ID: 7, Table: 12} sets the named fields; the rest are zero. Embedding writes another type as a field without a name. Methods and fields of the inner type are promoted to the outer type. The outer type is still not “a kind of” the inner type.

A tag is a string on a field (json:"id"). Encoding packages read tags. Your code usually ignores them.

Worked examples

Case 1: Ticket and order literals

Save as desk_structs.go. Named fields, then a print.

// desk_structs.go
package main

import "fmt"

type Ticket struct {
    ID    int
    Table int
}

type Order struct {
    Ticket Ticket
    Item   string
    Cents  int
}

func main() {
    t := Ticket{ID: 7, Table: 12}
    o := Order{Ticket: t, Item: "toast", Cents: 350}
    fmt.Printf("ticket %d table %d\n", o.Ticket.ID, o.Ticket.Table)
    fmt.Printf("%s %d cents\n", o.Item, o.Cents)
}

Run:

go run desk_structs.go

Output:

ticket 7 table 12
toast 350 cents

Order{Ticket: t, ...} is clearer than positional Order{t, "toast", 350}. Use names.

Case 2: Exported fields and a JSON tag

Save as ticket_json.go. note is lower-case, so encoding/json skips it. The tags rename the exported fields in the JSON.

// ticket_json.go
package main

import (
    "encoding/json"
    "fmt"
)

type Ticket struct {
    ID    int    `json:"id"`
    Table int    `json:"table"`
    note  string `json:"note"`
}

func main() {
    t := Ticket{ID: 7, Table: 12, note: "window"}
    b, err := json.Marshal(t)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(string(b))
}

Run:

go run ticket_json.go

Output:

{"id":7,"table":12}

note is in the struct and gone from the JSON. Other packages cannot write t.note either. Export the field (Note) when callers and encoders should see it.

Case 3: Embedding promotes fields and methods

Save as embed_table.go. Order embeds Table. o.Number is o.Table.Number. o.Label() is o.Table.Label().

// embed_table.go
package main

import "fmt"

type Table struct {
    Number int
    Seats  int
}

func (t Table) Label() string {
    return fmt.Sprintf("table %d", t.Number)
}

type Order struct {
    Table
    Item  string
    Cents int
}

func main() {
    o := Order{
        Table: Table{Number: 4, Seats: 2},
        Item:  "toast",
        Cents: 350,
    }
    fmt.Println(o.Number, o.Seats, o.Label())
    fmt.Println(o.Item, o.Cents)
}

Run:

go run embed_table.go

Output:

4 2 table 4
toast 350

Embedding is a shorter path to the inner fields. It is still a field. o.Table exists.

Case 4: Embedding is not inheritance

Save as not_a_table.go. A function that wants a Table will not take an Order. You pass o.Table.

// not_a_table.go
package main

import "fmt"

type Table struct {
    Number int
    Seats  int
}

type Order struct {
    Table
    Item string
}

func freeSeats(t Table) int {
    return t.Seats
}

func main() {
    o := Order{Table: Table{Number: 4, Seats: 2}, Item: "tea"}
    fmt.Println(freeSeats(o.Table))
}

Run:

go run not_a_table.go

Output:

2

freeSeats(o) does not compile: cannot use o (variable of struct type Order) as Table value. There is no subclass. There is a field.

The trap

A struct value is a copy. A method or function that takes Ticket (not *Ticket) cannot update the caller’s fields. Save as copy_update.go:

// copy_update.go
package main

import "fmt"

type Ticket struct {
    ID    int
    Table int
}

func (t Ticket) move(table int) {
    t.Table = table
}

func (t *Ticket) movePtr(table int) {
    t.Table = table
}

func main() {
    t := Ticket{ID: 7, Table: 12}
    t.move(4)
    fmt.Println("after value", t.Table)
    t.movePtr(4)
    fmt.Println("after pointer", t.Table)
}

Run:

go run copy_update.go

Output:

after value 12
after pointer 4

move changed a copy. The desk still thinks table 12. Use a pointer receiver when the method’s job is to change the struct. Use a value receiver when the method only reads (see methods later). Until then: if you mutate, pass *Ticket.

The boring rule

  • Name fields in literals. Leave unused fields zero.
  • Export fields that other packages (and JSON) must see. Keep the rest lower-case.
  • Tags are for encoders. Add them when you encode, not “just in case.”
  • Embed when the inner type is a part of the outer value and promotion helps. Otherwise use a named field (Ticket Ticket).
  • Do not pretend embedding is inheritance. Pass o.Table, not o, into functions that want Table.
  • Mutating methods take a pointer. Assignment copies the struct.

Try this

  1. In desk_structs.go, add a Shift struct (Name string, Tables int) and print one literal.
  2. In ticket_json.go, rename note to Note and run. The JSON should include "Note":"window" unless you add a tag.
  3. In embed_table.go, print o.Table.Number next to o.Number. They are the same slot.
  4. In not_a_table.go, uncomment a call freeSeats(o) in your head, then try it and read the compile error.