The struct keyword groups named fields into a single type. A type Order struct { ... } declaration is how you describe an order, a user, an HTTP request body or a config file in Go. Structs have no constructors and no inheritance. You add behavior with methods, reuse code by embedding one struct in another, and control encoding with struct tags (Go spec).
TL;DR
type Order struct { ... }declares a struct type. Each field has a name and a type, and fields of the same type can share a line.- A struct's zero value has every field set to its own zero value, so
var o Orderis ready to use without any setup. - Write composite literals with field names, as in
Order{ID: 1001, Total: 4999}.go vetflags unkeyed literals of types from other packages. - Only exported (capitalized) fields are visible to
encoding/jsonand other packages. A lowercase field is skipped without an error. - Struct tags like
json:"email,omitempty"control encoding. Useomitzero(Go 1.24) fortime.Timeand other structs, becauseomitemptynever omits them. - Methods with a pointer receiver can change the struct. Methods with a value receiver work on a copy.
- Embedding a type promotes its fields and methods to the outer struct. That is how Go reuses code without inheritance.
- Structs are comparable with
==only when every field is. Assigning a struct copies it, but slices and maps inside it are still shared.
How do you declare a struct in Go?
Write type, the name, struct and the fields in braces. Each field is a name followed by a type:
example.gogopackage main import ( "fmt" "time" ) type Order struct { ID int64 CustomerID int64 Status string Total int64 // cents CreatedAt time.Time } type Address struct { Street, City, Country string } func main() { var o Order fmt.Printf("%+v\n", o) fmt.Println(o.Status == "", o.CreatedAt.IsZero()) }
example.texttext{ID:0 CustomerID:0 Status: Total:0 CreatedAt:0001-01-01 00:00:00 +0000 UTC} true true
Address shows the short form. Fields with the same type can share a line, separated by commas. Most codebases still put one field per line, because it keeps diffs small and leaves room for a tag or a comment.
var o Order needs no constructor. Every field starts at its zero value: 0 for numbers, "" for strings, nil for pointers, slices and maps, and the zero time.Time for CreatedAt. The Go var keyword covers zero values in depth.
Like any type, a struct can be declared inside a function when only that function needs it. The Go type keyword shows a local loginRequest struct used to decode a JSON body.
How do you create a struct value?
You'll mostly use the zero value from var, a composite literal with field names, or a pointer to a literal with &:
example.gogokeyed := Order{ID: 1001, CustomerID: 42, Status: "paid", Total: 4999} partial := Order{ID: 1002, Status: "pending"} positional := Order{1003, 42, "paid", 1250} p := &Order{ID: 1004} p.Status = "shipped"
example.texttext{ID:1001 CustomerID:42 Status:paid Total:4999} {ID:1002 CustomerID:0 Status:pending Total:0} {ID:1003 CustomerID:42 Status:paid Total:1250} {ID:1004 CustomerID:0 Status:shipped Total:0}
A keyed literal can list any subset of fields in any order, and the rest get their zero value. A positional literal has to list every field in declaration order, which makes it fragile. Add a Currency field to Order and every positional literal stops compiling, while the keyed ones keep working.
For struct types from other packages, go vet reports positional literals through its composites analyzer:
example.gogoaddr := net.TCPAddr{net.ParseIP("10.0.0.5"), 5432, ""}
example.texttextmain.go:9:10: net.TCPAddr struct literal uses unkeyed fields
p.Status works on a pointer without writing (*p).Status. Go dereferences a pointer to a struct automatically when you access a field.
How do you set an optional pointer field in one line?
Use new with a value, which works since Go 1.26. Pointer fields are the usual way to tell "not sent" from "sent as zero" in a PATCH request body, and before Go 1.26 you needed a temporary variable or a helper function for each one:
example.gogotype UpdateUser struct { Name *string `json:"name,omitempty"` Email *string `json:"email,omitempty"` Age *int `json:"age,omitempty"` } func main() { patch := UpdateUser{ Email: new("[email protected]"), Age: new(31), } out, _ := json.Marshal(patch) fmt.Println(string(out)) }
example.texttext{"email":"[email protected]","age":31}
new(31) allocates an int, stores 31 in it and returns the pointer (Go 1.26 release notes). With go 1.25 in go.mod, the compiler rejects it:
example.texttext./main.go:16:10: new("[email protected]") requires go1.26 or later (-lang was set to go1.25; check go.mod)
Why are lowercase struct fields missing from JSON?
Because they are unexported, and other packages can't see unexported fields. encoding/json is another package, so it skips them without an error:
example.gogotype User struct { ID int64 Email string password string role string } func main() { u := User{ID: 7, Email: "[email protected]", password: "hunter2", role: "admin"} out, err := json.Marshal(u) fmt.Println(string(out), err) var in User err = json.Unmarshal([]byte(`{"ID":8,"Email":"[email protected]","role":"admin"}`), &in) fmt.Printf("%+v %v\n", in, err) }
example.texttext{"ID":7,"Email":"[email protected]"} <nil> {ID:8 Email:[email protected] password: role:} <nil>
Both calls return a nil error, and the role in the input is dropped without a word. When a Go API returns {} or a field stays empty after decoding, check for this first. The fix is to capitalize the field and use a tag to keep the lowercase JSON name.
The same rule applies to every package that reads fields through reflection: encoding/xml, database/sql scanners, GORM, YAML libraries and template execution. You can use it on purpose, too. An unexported field is a safe place for data that must never be encoded, like a database handle or a cache.
What are struct tags in Go?
A struct tag is a string literal after a field's type. Go itself ignores it, and packages read it through reflection to decide how to handle that field. The JSON tag is the one you'll write most:
example.gogotype User struct { ID int64 `json:"id"` Email string `json:"email"` Nickname string `json:"nickname,omitempty"` PasswordHash string `json:"-"` DeletedAt time.Time `json:"deleted_at,omitempty"` LastLoginAt time.Time `json:"last_login_at,omitzero"` } func main() { u := User{ID: 7, Email: "[email protected]", PasswordHash: "$2a$10$..."} enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") enc.Encode(u) }
example.texttext{ "id": 7, "email": "[email protected]", "deleted_at": "0001-01-01T00:00:00Z" }
Going through the output field by field:
json:"id"renames the field in the output.omitemptydrops the field when it isfalse,0,"",nilor an empty slice or map.Nicknameis empty, so it is gone.json:"-"never encodes the field.PasswordHashstays out of every response.omitemptydoesn't work on structs.DeletedAtis a zerotime.Time, and it still shows up as0001-01-01T00:00:00Z, which clients tend to parse as a real date.omitzero, added in Go 1.24, drops a field when it is the zero value of its type. For types with anIsZero() boolmethod, liketime.Time, it calls that method.LastLoginAtis gone (Go 1.24 release notes).
For new code, use omitzero on struct fields and time.Time. omitempty is still fine for strings, numbers, slices and maps.
A field can carry tags for several packages, separated by spaces: `json:"email" db:"email" validate:"required,email"`. Each package reads only its own key with reflect.StructTag.Get:
example.gogof, _ := reflect.TypeFor[User]().FieldByName("Nickname") fmt.Println(f.Tag.Get("json"))
example.texttextnickname,omitempty
Why is my struct tag ignored?
Usually because of a space or a missing quote. The format is strict: key:"value", with no space after the colon. json: "email" looks fine to a human, but Tag.Get("json") returns an empty string, so the field is encoded as Email. The compiler accepts any string as a tag, so it won't complain. go vet will, through its structtag analyzer, and it also catches two fields with the same JSON name:
example.gogotype User struct { Email string `json: "email"` Name string `json:"name" db:"name"` ID int64 `json:"id"` Ref int64 `json:"id"` }
example.texttextmain.go:4:2: struct field tag `json: "email"` not compatible with reflect.StructTag.Get: bad syntax for struct tag value main.go:7:2: struct field Ref repeats json tag "id" also at main.go:6
Editors that run gopls show the same warnings as you type.
How do methods work on structs?
A method is a function with a receiver, written before the name. The receiver is either a value (c Cart) or a pointer (c *Cart), and the choice decides whether the method can change the struct:
example.gogotype Cart struct { Items []string Total int64 } func (c Cart) AddValue(item string, price int64) { c.Items = append(c.Items, item) c.Total += price } func (c *Cart) Add(item string, price int64) { c.Items = append(c.Items, item) c.Total += price } func main() { var c Cart c.AddValue("keyboard", 4999) fmt.Println(len(c.Items), c.Total) c.Add("keyboard", 4999) fmt.Println(len(c.Items), c.Total) }
example.texttext0 0 1 4999
AddValue compiles and runs, but the cart stays empty. A value receiver is a copy of the struct, so the method updates the copy and then throws it away. New Go code hits this bug a lot, because no error or warning points at it. Add gets a pointer and changes the caller's cart.
You don't need to write (&c).Add(...). When c is addressable, Go takes the address for you. The reverse also works: a pointer can call value-receiver methods.
Use a pointer receiver when the method changes the struct, when the struct is large, or when it contains a sync.Mutex or similar field that must not be copied. Use a value receiver for small, immutable values like a Money or a Point. If any method on a type needs a pointer receiver, give all of them pointer receivers. Mixing the two makes the method set harder to reason about, because only *Cart then has every method, and only *Cart satisfies interfaces that need all of them (Go spec).
How does struct embedding work in Go?
A field written with only a type and no name is an embedded field. Its fields and methods are promoted, which means you can use them as if they were declared on the outer struct:
example.gogotype Timestamps struct { CreatedAt time.Time UpdatedAt time.Time } func (t *Timestamps) Touch(now time.Time) { if t.CreatedAt.IsZero() { t.CreatedAt = now } t.UpdatedAt = now } type Order struct { ID int64 Timestamps } type User struct { ID int64 Email string Timestamps } func main() { now := time.Date(2026, 9, 26, 9, 0, 0, 0, time.UTC) var o Order o.Touch(now) fmt.Println(o.CreatedAt.Format(time.DateOnly), o.Timestamps.UpdatedAt.Format(time.Kitchen)) u := User{ID: 7, Email: "[email protected]", Timestamps: Timestamps{CreatedAt: now}} fmt.Println(u.CreatedAt.Year()) }
example.texttext2026-09-26 9:00AM 2026
o.Touch(now) is shorthand for o.Timestamps.Touch(now). The embedded field still exists under its type name, which is also how you set it in a literal: Timestamps: Timestamps{...}. encoding/json flattens embedded structs too, so the Order above encodes as {"ID":0,"CreatedAt":"2026-09-26T09:00:00Z","UpdatedAt":"2026-09-26T09:00:00Z"}.
Embedding is not inheritance. An Order is not a Timestamps, and you can't pass an Order to a function that takes a Timestamps. When Touch runs, its receiver is the inner Timestamps. It has no way to reach the Order around it or call methods that Order defines. A subclass in Java or Python can do that, which is where "which override runs?" questions come from. In Go they don't come up.
When the outer struct declares a field or method with the same name, it wins, and the embedded one is still reachable through the type name:
example.gogotype Base struct{ ID int64 } func (Base) Describe() string { return "base" } type Order struct { Base ID string } func (Order) Describe() string { return "order" } func main() { o := Order{Base: Base{ID: 42}, ID: "ord_42"} fmt.Println(o.ID, o.Base.ID) fmt.Println(o.Describe(), o.Base.Describe()) }
example.texttextord_42 42 order base
Can you embed an interface in a struct?
Yes, and it is a common way to wrap a type while overriding one method. HTTP middleware that records the status code embeds http.ResponseWriter, gets Header and Write for free and replaces only WriteHeader:
example.gogotype statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } func logStatus(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, req) slog.Info("request", "path", req.URL.Path, "status", rec.status) }) }
example.texttext2026/09/26 09:04:44 INFO request path=/orders/99 status=404
Because *statusRecorder has all three methods, it satisfies http.ResponseWriter and can be passed to the next handler. If the embedded interface is nil, calling one of its methods panics, so always set it in the literal.
Should you embed sync.Mutex in a struct?
Not in an exported type. Embedding promotes Lock and Unlock into the type's public API, so any caller can lock your cache from the outside:
example.gogotype Cache struct { sync.Mutex items map[string]string }
A named, unexported field keeps the lock an implementation detail:
example.gogotype SafeCache struct { mu sync.Mutex items map[string]string } func (c *SafeCache) Get(key string) (string, bool) { c.mu.Lock() defer c.mu.Unlock() v, ok := c.items[key] return v, ok }
The zero value of sync.Mutex is an unlocked mutex, so SafeCache needs no setup for the lock. It still needs its map created before the first write.
Can you compare and copy structs in Go?
You can compare two structs with == when every field is comparable. The comparison is field by field. That makes small structs good value types and good map keys:
example.gogotype Money struct { Amount int64 Currency string } type RouteKey struct { Method string Path string } func main() { fmt.Println(Money{4999, "EUR"} == Money{4999, "EUR"}) hits := map[RouteKey]int{} hits[RouteKey{"GET", "/orders"}]++ hits[RouteKey{"GET", "/orders"}]++ hits[RouteKey{"POST", "/orders"}]++ fmt.Println(hits[RouteKey{"GET", "/orders"}], len(hits)) }
example.texttexttrue 2 2
A composite key like RouteKey is cleaner than gluing strings together as "GET /orders", and it can't collide when a path contains a space. Positional literals are fine here, because RouteKey is declared in the same package and has two obvious fields.
Slices, maps and functions are not comparable, so a struct that contains one isn't either. The compiler tells you:
example.gogotype Order struct { ID int64 Items []string } func main() { a := Order{ID: 1} b := Order{ID: 1} fmt.Println(a == b) }
example.texttext./main.go:13:14: invalid operation: a == b (struct containing []string cannot be compared)
Compare such structs field by field, with slices.Equal for the slice, or with reflect.DeepEqual in tests.
Does assigning a struct copy it?
Yes, but the copy is shallow. Every field is copied, and a slice or map field is copied as a header that still points at the same data:
example.gogotype Order struct { ID int64 Items []string Meta map[string]string } func main() { original := Order{ID: 1, Items: []string{"keyboard"}, Meta: map[string]string{"source": "web"}} copied := original copied.ID = 2 copied.Items[0] = "mouse" copied.Meta["source"] = "api" fmt.Println(original.ID, original.Items, original.Meta) }
example.texttext1 [mouse] map[source:api]
Changing copied.ID left the original alone. Changing an element of copied.Items or a key in copied.Meta changed the original too. To get an independent copy, clone those fields yourself with slices.Clone and maps.Clone.
The same copy happens when you pass a struct by value to a function. For a struct that contains a sync.Mutex, that copy is a bug, because the copy gets its own lock. go vet reports it through the copylocks analyzer:
example.gogotype Counter struct { mu sync.Mutex hits int } func report(c Counter) { fmt.Println(c.hits) }
example.texttextmain.go:13:15: report passes lock by value: shop.Counter contains sync.Mutex main.go:19:9: call of report copies lock value: shop.Counter contains sync.Mutex
The fix is func report(c *Counter).
What is an anonymous struct in Go?
It is a struct type written inline with no name. You use it when a shape is needed once. The two places you'll see it most are table-driven tests and decoding part of a JSON response:
example.gogovar resp struct { Data struct { Status string `json:"status"` } `json:"data"` } body := `{"data":{"status":"shipped","carrier":"DHL"},"meta":{"request_id":"abc"}}` if err := json.Unmarshal([]byte(body), &resp); err != nil { panic(err) } fmt.Println(resp.Data.Status) tests := []struct { name string input string want string }{ {"trims spaces", " [email protected] ", "[email protected]"}, {"already clean", "[email protected]", "[email protected]"}, } for _, tt := range tests { got := strings.ToLower(strings.TrimSpace(tt.input)) fmt.Println(tt.name, got == tt.want) }
example.texttextshipped trims spaces true already clean true
The decoder fills only the fields the struct declares, so carrier and meta are ignored. You don't need a named type for a response you read one field from. Once the same shape shows up in two places, give it a name.
What is struct{} used for?
struct{} is a struct with no fields. Its size is zero bytes, so it is the value type of choice when only the presence of something matters. The two common cases are a set built on a map and a channel used only to signal:
example.gogoseen := map[string]struct{}{} for _, id := range []string{"evt_1", "evt_2", "evt_1"} { if _, dup := seen[id]; dup { fmt.Println("skipping duplicate", id) continue } seen[id] = struct{}{} } fmt.Println(len(seen), unsafe.Sizeof(struct{}{})) done := make(chan struct{}) go func() { defer close(done) }() <-done fmt.Println("worker finished")
example.texttextskipping duplicate evt_1 2 0 worker finished
map[string]struct{} says "set of strings" more clearly than map[string]bool, where a reader has to ask what false means. chan struct{} says that no data travels on the channel, only the fact that it was closed. ctx.Done() returns a <-chan struct{} for the same reason.
Does field order change a struct's size?
Yes. Each field is aligned to its size, so the compiler inserts padding between a small field and a larger one that follows it. The same five fields take twice the memory in the wrong order on a 64-bit machine:
example.gogotype EventLoose struct { Active bool ID int64 Retried bool Attempts int32 Urgent bool } type EventPacked struct { ID int64 Attempts int32 Active bool Retried bool Urgent bool } func main() { fmt.Println(unsafe.Sizeof(EventLoose{}), unsafe.Sizeof(EventPacked{})) }
example.texttext32 16
EventLoose pads 7 bytes after Active so ID starts on an 8-byte boundary, then more after Retried and Urgent. EventPacked orders fields from largest to smallest and wastes only 1 byte at the end.
For most structs this doesn't matter, and grouping fields by meaning is more readable. It starts to matter when you keep millions of values in a slice or a cache. The fieldalignment analyzer in golang.org/x/tools reports structs that could be smaller.
Go struct vs class: what's the difference?
A Go struct holds data and can have methods, which covers most of what a class does in Java, C# or Python. The rest differs on purpose:
| Go struct | Class (Java, C#, Python) | |
|---|---|---|
| Constructor | None. Zero value, or a NewX function by convention | Special constructor method |
| Reuse | Embedding (composition) | Inheritance, plus composition |
| Polymorphism | Interfaces, satisfied implicitly | Base classes and declared interfaces |
| Visibility | Per package, by capitalization | public, private, protected per member |
| Methods | Declared outside the type, anywhere in the package | Declared inside the class body |
| Copy semantics | Value by default, & for a pointer | Reference by default |
When a struct needs validation or defaults, write a plain function that returns it. The convention is NewX, and returning an error with it is normal:
example.gogotype Client struct { baseURL string http *http.Client retries int } func NewClient(baseURL string) (*Client, error) { if baseURL == "" { return nil, errors.New("baseURL is required") } return &Client{ baseURL: baseURL, http: &http.Client{Timeout: 10 * time.Second}, retries: 3, }, nil }
Unexported fields mean callers outside the package can only get a Client through NewClient, with the defaults applied. Since Go has no super and no overriding, you never climb a class hierarchy to find out what a method does. A struct's behavior is its own methods plus the methods it embeds, and you can read all of them in one package. That helps a lot when a Go codebase grows.
Where LevelUpGo fits
LevelUpGo teaches Go through exercises that run real Go code in the browser. Composite Types builds your first struct, constructor functions, methods and embedding step by step, then uses them in a small project. Pointers & Memory covers pointers to structs, new, and value vs pointer receivers, ending with a feature-flag store that updates structs in place. Interfaces & Polymorphism shows how structs satisfy interfaces without declaring it. The Training Ground has short standalone exercises for practicing outside a course. For the other 24 reserved words, see Go keywords: all 25 explained.
FAQ
Is struct a keyword in Go?
Yes. struct is one of Go's 25 reserved keywords, so you can't use it as a variable or function name. It starts a struct type, which is almost always named with type, as in type Order struct { ... }. It also appears in anonymous structs and in the empty struct struct{}.
Does Go have classes?
No. Go has structs with methods, interfaces and embedding instead. A struct holds the data, methods add behavior, interfaces provide polymorphism, and embedding reuses code through composition. There is no inheritance and no constructor keyword. A NewX function plays that role by convention.
Should I use a value or a pointer receiver?
Use a pointer receiver if the method changes the struct, if the struct is large, or if it contains a sync.Mutex. Use a value receiver for small values that never change, like a Money or a Point. If one method on a type needs a pointer receiver, make them all pointer receivers so the type behaves consistently.
Why does json.Marshal return an empty object for my struct?
Because the struct's fields are unexported. encoding/json can only see fields that start with a capital letter, and it skips the others without an error. Rename email to Email and add `json:"email"` to keep the lowercase name in the JSON.
What is the difference between omitempty and omitzero?
omitempty drops false, 0, "", nil and empty slices and maps, but never drops a struct, so a zero time.Time still appears as 0001-01-01T00:00:00Z. omitzero, added in Go 1.24, drops any field that holds its type's zero value, and it uses the type's IsZero method when there is one.
Sources
- The Go Programming Language Specification, Struct types: https://go.dev/ref/spec#Struct_types
- The Go Programming Language Specification, Composite literals: https://go.dev/ref/spec#Composite_literals
- The Go Programming Language Specification, Method sets: https://go.dev/ref/spec#Method_sets
- The Go Programming Language Specification, Comparison operators: https://go.dev/ref/spec#Comparison_operators
- encoding/json, Marshal: https://pkg.go.dev/encoding/json#Marshal
- reflect, StructTag: https://pkg.go.dev/reflect#StructTag
- Go 1.24 Release Notes, omitzero: https://go.dev/doc/go1.24
- Go 1.26 Release Notes, new with an expression: https://go.dev/doc/go1.26
- go vet analyzers (composites, structtag, copylocks): https://pkg.go.dev/cmd/vet
