Back to Blog

The Go struct Keyword: Fields, Tags, Embedding and Methods

How Go's struct keyword works: declaring fields, zero values and composite literals, exported fields and JSON, struct tags with omitempty and omitzero, value vs pointer receivers, embedding, comparison and copying, the empty struct and field order.

The Go struct Keyword: Fields, Tags, Embedding and Methods

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 Order is ready to use without any setup.
  • Write composite literals with field names, as in Order{ID: 1001, Total: 4999}. go vet flags unkeyed literals of types from other packages.
  • Only exported (capitalized) fields are visible to encoding/json and other packages. A lowercase field is skipped without an error.
  • Struct tags like json:"email,omitempty" control encoding. Use omitzero (Go 1.24) for time.Time and other structs, because omitempty never 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.gogo
package 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.gogo
keyed := 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.gogo
addr := net.TCPAddr{net.ParseIP("10.0.0.5"), 5432, ""}
example.texttext
main.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.gogo
type 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.gogo
type 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.gogo
type 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.
  • omitempty drops the field when it is false, 0, "", nil or an empty slice or map. Nickname is empty, so it is gone.
  • json:"-" never encodes the field. PasswordHash stays out of every response.
  • omitempty doesn't work on structs. DeletedAt is a zero time.Time, and it still shows up as 0001-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 an IsZero() bool method, like time.Time, it calls that method. LastLoginAt is 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.gogo
f, _ := reflect.TypeFor[User]().FieldByName("Nickname")
fmt.Println(f.Tag.Get("json"))
example.texttext
nickname,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.gogo
type User struct {
	Email string `json: "email"`
	Name  string `json:"name" db:"name"`
	ID    int64  `json:"id"`
	Ref   int64  `json:"id"`
}
example.texttext
main.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.gogo
type 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.texttext
0 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.gogo
type 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.texttext
2026-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.gogo
type 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.texttext
ord_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.gogo
type 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.texttext
2026/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.gogo
type Cache struct {
	sync.Mutex
	items map[string]string
}

A named, unexported field keeps the lock an implementation detail:

example.gogo
type 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.gogo
type 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.texttext
true
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.gogo
type 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.gogo
type 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.texttext
1 [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.gogo
type Counter struct {
	mu   sync.Mutex
	hits int
}

func report(c Counter) {
	fmt.Println(c.hits)
}
example.texttext
main.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.gogo
var 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.texttext
shipped
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.gogo
seen := 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.texttext
skipping 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.gogo
type 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.texttext
32 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 structClass (Java, C#, Python)
ConstructorNone. Zero value, or a NewX function by conventionSpecial constructor method
ReuseEmbedding (composition)Inheritance, plus composition
PolymorphismInterfaces, satisfied implicitlyBase classes and declared interfaces
VisibilityPer package, by capitalizationpublic, private, protected per member
MethodsDeclared outside the type, anywhere in the packageDeclared inside the class body
Copy semanticsValue by default, & for a pointerReference 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.gogo
type 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

Write Go like a senior engineer

Interactive lessons in your browser. The first ones are free.

Try a free lessonOr create a free account