1) Defining Structs: Fields, Tags, and Zero Values
A struct groups related data into a single type. It is Go’s primary way to model “things” with multiple properties.
Basic struct definition
package main
type User struct {
ID int
Name string
Email string
Admin bool
}Field names that start with an uppercase letter are exported (visible to other packages and to encoders like encoding/json). Lowercase field names are unexported.
Zero values for struct fields
When you declare a struct variable without initializing it, each field gets its type’s zero value:
int→0string→""bool→false- pointers/slices/maps/functions/interfaces →
nil
var u User
// u.ID == 0, u.Name == "", u.Email == "", u.Admin == falseStruct tags (briefly): JSON field names and options
Tags are string metadata attached to fields. The most common beginner use is JSON marshaling/unmarshaling.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Admin bool `json:"admin"`
}json:"id"sets the JSON key name.omitemptyomits the field if it has a zero value (e.g., empty string).
Tags are read by packages via reflection; you don’t “use” them directly in normal code.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
2) Constructing Struct Values: Literals, Pointers, and new
Composite literals (recommended for clarity)
You can create a struct value with a composite literal. Prefer named fields for readability and stability when fields change.
u := User{
ID: 1,
Name: "Ava",
Email: "ava@example.com",
Admin: false,
}You can also use positional fields, but it is easier to break when the struct changes:
// Avoid in most codebases unless the struct is tiny and stable.
u2 := User{1, "Ava", "ava@example.com", false}Pointers to structs and automatic address-taking
Often you want a pointer so methods can mutate the struct, or to avoid copying large structs.
up := &User{
ID: 2,
Name: "Noah",
}Go lets you access fields through a pointer without explicit dereferencing:
up.Name = "Noah R." // same as (*up).Name = "Noah R."new(T) vs &T{} vs T{}
var u Useroru := User{}creates a value with zero values.p := new(User)allocates a zero-valued User and returns*User.p := &User{...}allocates and initializes, returning*User.
u := User{} // value
p1 := new(User) // *User, all fields zero
p2 := &User{Name: "Mia"} // *User with Name setIn practice: use User{...} for values and &User{...} for pointers. Use new when you specifically want a pointer to a zero value without setting fields.
3) Methods: Attaching Behavior to Data
A method is a function with a receiver. Receivers let you write behavior “on” a type.
Defining methods
type User struct {
ID int
Name string
Email string
}
func (u User) DisplayName() string {
if u.Name == "" {
return "(unknown)"
}
return u.Name
}This method uses a value receiver (u User), so it operates on a copy of the struct.
Pointer receivers for mutation
If a method needs to modify the receiver, use a pointer receiver:
func (u *User) SetEmail(email string) {
u.Email = email
}Calling it is still simple—Go automatically takes the address when possible:
u := User{Name: "Ava"}
u.SetEmail("ava@example.com") // compiler uses &uMethod sets (what methods are available on values vs pointers)
- A value of type
Thas methods with receiver(t T). - A pointer of type
*Thas methods with receiver(t T)and(t *T).
This matters when a type needs to satisfy an interface: if an interface requires a method that has a pointer receiver, only *T satisfies it (not T).
Guidelines for choosing receivers
- Use a pointer receiver when the method mutates the receiver.
- Use a pointer receiver when copying the value would be expensive (large structs).
- Use a value receiver for small, immutable-like data where copying is cheap and you want simpler semantics.
- Be consistent: if some methods need pointer receivers, many teams make all methods pointer receivers for that type to avoid confusion.
4) Embedding: Composition Over Inheritance
Go does not have class inheritance. Instead, it encourages composition: build types from other types. Embedding is a special form of composition where an anonymous field’s members are promoted.
Embedding a struct type
type Audit struct {
CreatedAt string
UpdatedAt string
}
type Task struct {
Audit // embedded
ID int
Title string
}Because Audit is embedded, its fields are promoted and can be accessed directly:
t := Task{ID: 1, Title: "Write docs"}
t.CreatedAt = "2026-01-16" // promoted field (actually t.Audit.CreatedAt)Promoted methods
Methods on an embedded type are also promoted, so the outer type can call them as if they were its own (subject to method sets and pointer/value rules).
type Audit struct {
CreatedAt string
UpdatedAt string
}
func (a *Audit) Touch(now string) {
a.UpdatedAt = now
}
type Task struct {
Audit
Title string
}
// Task gets Touch promoted (when you have *Task or Task depending on receiver rules)When embedding is appropriate
- When the embedded type represents a reusable “part” of the outer type (e.g., auditing fields, common configuration, shared behavior).
- When promoted fields/methods improve ergonomics without hiding meaning.
- Avoid embedding just to “inherit” a large API; it can make the outer type’s surface area confusing.
If you want composition without promotion, use a named field instead:
type Task struct {
AuditInfo Audit // not embedded; access via t.AuditInfo.CreatedAt
Title string
}5) Small Project: A User Type with Validation, Formatting, and JSON
This mini project models a user with a few rules:
Nameis required.Emailis optional, but if present it must contain@.- Provide methods to validate and format output.
- Show JSON marshaling using tags.
Step 1: Define the struct with JSON tags
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Admin bool `json:"admin"`
}Step 2: Add a validation method
Validation typically does not need to mutate the receiver, so a value receiver is fine. If you plan to cache results or normalize fields during validation, switch to a pointer receiver.
func (u User) Validate() error {
if strings.TrimSpace(u.Name) == "" {
return errors.New("name is required")
}
if u.Email != "" && !strings.Contains(u.Email, "@") {
return errors.New("email must contain @")
}
return nil
}Step 3: Add a formatting method
This method returns a human-friendly string without changing the user.
func (u User) Label() string {
role := "user"
if u.Admin {
role = "admin"
}
if u.Email == "" {
return fmt.Sprintf("%s (#%d, %s)", u.Name, u.ID, role)
}
return fmt.Sprintf("%s <%s> (#%d, %s)", u.Name, u.Email, u.ID, role)
}Step 4: Add a mutating method (pointer receiver)
Normalization is a common reason to use pointer receivers.
func (u *User) Normalize() {
u.Name = strings.TrimSpace(u.Name)
u.Email = strings.TrimSpace(strings.ToLower(u.Email))
}Step 5: Use the type and marshal to JSON
This example shows a typical flow: construct, normalize, validate, then marshal.
func main() {
u := &User{
ID: 10,
Name: " Ava ",
Email: "AVA@EXAMPLE.COM",
Admin: true,
}
u.Normalize()
if err := u.Validate(); err != nil {
panic(err)
}
fmt.Println(u.Label())
b, err := json.MarshalIndent(u, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(b))
}Because of the JSON tags:
- The output keys will be
id,name,email,admin. - If
Emailis empty, it will be omitted due toomitempty.