Structs in Go: how to model data without traditional classes
Structs in Go explained: methods, composition, JSON tags, visibility, and differences from classical object orientation.

Go has no classes. No inheritance. No constructors in the traditional sense. And yet, you can model complex domains without any trouble. It’s not that Go eliminates data modeling: it eliminates some of the theater that sometimes surrounds object orientation.
If you come from Java or Kotlin, this feels uncomfortable at first. You’re used to class hierarchies, extends, abstract, inheritance patterns you’ve been practicing for years. Go tells you: you don’t need any of that. What you need is a struct, some methods, and composition. And it turns out that with those tools you build software that’s just as expressive and considerably easier to read.
Structs are the fundamental modeling mechanism in Go. Everything goes through them: your domain entities, your DTOs, your configurations, your API responses. Understanding them well isn’t optional. It’s the foundation on which any Go program beyond a script is built.
Defining a struct: fields, types, and zero values
A struct in Go is a collection of named, typed fields. Nothing more. It has no associated methods in its definition (that comes later). It has no inheritance. It has no per-field visibility via keywords like private or protected. It is, by design, the simplest data structure you can have.
type User struct {
ID int
Name string
Email string
Active bool
CreatedAt time.Time
}Each field has an explicit type. No magic inference, no native optional types (though you can use pointers for that). And something that surprises many people: all fields have a default zero value. Not null, not nil (except for pointers, slices, maps, and channels). A concrete and deterministic zero value.
| Type | Zero value |
|---|---|
int, float64 | 0 |
string | "" |
bool | false |
*T (pointer) | nil |
[]T (slice) | nil |
map[K]V | nil |
time.Time | Zero date (year 1) |
This means an empty User{} is perfectly valid: ID is 0, Name is "", Active is false, CreatedAt is the zero date. It doesn’t throw exceptions, it’s not null. It has a defined, predictable state.
var u User
fmt.Println(u.Name) // "" (empty string)
fmt.Println(u.Active) // false
fmt.Println(u.ID) // 0If you come from Java, think about this: you don’t need to initialize fields. You don’t need constructors that assign default values. The language already guarantees that every type has a safe initial state. This eliminates an entire category of NullPointerException-related bugs.
Creating instances: literal syntax and constructor functions
Go doesn’t have a new keyword in the Java sense. There are two main ways to create structs, and each has its use.
Literal syntax
The most direct. You create the struct and assign the fields you need:
user := User{
ID: 1,
Name: "Roger",
Email: "roger@oshy.tech",
Active: true,
}Fields you don’t specify take their zero value. This is intentional and useful: if a boolean should default to false, simply don’t include it.
You can also create structs without naming the fields, but don’t do it:
// This compiles, but it's brittle and unreadable
user := User{1, "Roger", "roger@oshy.tech", true, time.Now()}If someone adds a field to the struct tomorrow, this code breaks. Always use the syntax with field names.
Constructor functions
Go has no constructors as a special method of the type. Instead, the convention is to create a function starting with New:
func NewUser(name, email string) *User {
return &User{
Name: name,
Email: email,
Active: true,
CreatedAt: time.Now(),
}
}Notice several details:
- It returns a pointer (
*User), not a value. This is the usual convention when the struct will be modified or shared. - It sets default values (
Active: true,CreatedAt: time.Now()). - It doesn’t need a special keyword. It’s a normal function. No magic.
This convention is so strong in Go that when you see NewX you immediately know it’s a constructor. No annotations, no reflection, no framework registering anything.
The & operator
You can create a pointer to a struct directly with &:
user := &User{Name: "Roger"}This is equivalent to creating the struct and then taking its address. It’s idiomatic and you’ll see it everywhere. If you’re not comfortable with pointers in Go, I recommend reading Pointers in Go first before continuing.
Methods: value receivers vs pointer receivers
This is where Go starts to differentiate itself from mere data structures. Structs can have associated methods, but they’re not defined inside the struct (as you would in a class). They’re defined outside, with a receiver.
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}The (r Rectangle) before the method name is the receiver. It tells Go that this method belongs to the Rectangle type. To use it:
rect := Rectangle{Width: 10, Height: 5}
fmt.Println(rect.Area()) // 50
fmt.Println(rect.Perimeter()) // 30So far everything seems cosmetic. The real difference shows up with pointer receivers.
Value receiver vs pointer receiver
A value receiver works on a copy of the struct. A pointer receiver works on the original.
// Value receiver: does NOT modify the original
func (r Rectangle) Scale(factor float64) Rectangle {
return Rectangle{
Width: r.Width * factor,
Height: r.Height * factor,
}
}
// Pointer receiver: DOES modify the original
func (r *Rectangle) ScaleInPlace(factor float64) {
r.Width *= factor
r.Height *= factor
}rect := Rectangle{Width: 10, Height: 5}
scaled := rect.Scale(2)
fmt.Println(rect.Width) // 10 (unchanged)
fmt.Println(scaled.Width) // 20
rect.ScaleInPlace(2)
fmt.Println(rect.Width) // 20 (changed)Practical rules for choosing:
- Use pointer receiver if the method modifies the struct.
- Use pointer receiver if the struct is large (avoid copying a lot of memory).
- Use value receiver if the struct is small and immutable (like a coordinate or a time range).
- Be consistent: if one method of the type uses a pointer receiver, all of them should. Mixing is confusing and can cause subtle bugs with interfaces.
In practice, most methods in backend code use pointer receivers. Structs typically represent services, repositories, or entities that are modified or too large to copy on every call.
Composition over inheritance: embedded structs
Go has no inheritance. The word extends doesn’t exist. Instead, Go offers composition through embedded structs. The difference isn’t just syntactic: it fundamentally changes how you think about the relationship between types.
type Address struct {
Street string
City string
Country string
}
type Person struct {
Name string
Age int
Address // embedded field (no explicit name)
}The Person struct doesn’t “inherit from” Address. It contains an Address. But since it’s an embedded field (no explicit name), its fields are promoted: you can access them directly.
p := Person{
Name: "Roger",
Age: 32,
Address: Address{
Street: "Main Street 1",
City: "Barcelona",
Country: "Spain",
},
}
fmt.Println(p.City) // "Barcelona" (promoted)
fmt.Println(p.Address.City) // "Barcelona" (explicit access, also valid)Both forms work. And if Address has methods, those methods are promoted too:
func (a Address) FullAddress() string {
return fmt.Sprintf("%s, %s, %s", a.Street, a.City, a.Country)
}
fmt.Println(p.FullAddress()) // "Main Street 1, Barcelona, Spain"Why composition and not inheritance
Inheritance creates vertical coupling. If you change the parent class, you affect all children. If you have multiple levels of inheritance, tracing where a behavior comes from becomes an archaeological exercise.
Composition is horizontal. A Person contains an Address. If tomorrow you need it to also contain a ContactInfo, you add it. You don’t need to restructure a hierarchy. You don’t break implicit inheritance contracts.
type ContactInfo struct {
Phone string
Email string
}
type Person struct {
Name string
Age int
Address
ContactInfo
}
p := Person{
Name: "Roger",
Age: 32,
Address: Address{City: "Barcelona"},
ContactInfo: ContactInfo{Email: "roger@oshy.tech"},
}
fmt.Println(p.Email) // "roger@oshy.tech"
fmt.Println(p.City) // "Barcelona"If two embedded structs have a field with the same name, Go doesn’t resolve it automatically. It forces you to be explicit. This is a design decision: ambiguity is resolved at compile time, not at runtime.
type A struct { Name string }
type B struct { Name string }
type C struct {
A
B
}
var c C
// c.Name <- compile error: ambiguous
c.A.Name = "from A" // this worksThis eliminates an entire category of bugs that in languages with multiple inheritance (C++, Python) are resolved with precedence rules nobody remembers.
JSON tags: marshaling and unmarshaling
Struct tags are one of Go’s most useful and least glamorous features. They let you add metadata to fields that libraries can read via reflection. The most common case: controlling how a struct is serialized to JSON.
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
InStock bool `json:"in_stock"`
Description string `json:"description,omitempty"`
InternalSKU string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}Each tag is a string between backticks following the key:"value" convention. For JSON:
json:"name"defines the field name in JSON.json:"description,omitempty"omits the field if it has its zero value (empty string, 0, false, nil).json:"-"excludes the field from serialization. Useful for internal fields that shouldn’t appear in a REST API response.
Marshaling: struct to JSON
p := Product{
ID: 1,
Name: "Mechanical Keyboard",
Price: 89.99,
InStock: true,
InternalSKU: "KB-001-MX",
}
data, err := json.Marshal(p)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))Result:
{"id":1,"name":"Mechanical Keyboard","price":89.99,"in_stock":true,"created_at":"0001-01-01T00:00:00Z"}Notice: description doesn’t appear because we used omitempty and it was empty. InternalSKU doesn’t appear because we used "-". Field names follow the tag convention, not Go’s.
Unmarshaling: JSON to struct
raw := `{"id":2,"name":"Ergonomic Mouse","price":45.50,"in_stock":false}`
var p Product
if err := json.Unmarshal([]byte(raw), &p); err != nil {
log.Fatal(err)
}
fmt.Println(p.Name) // "Ergonomic Mouse"
fmt.Println(p.Price) // 45.5Unmarshaling ignores JSON fields with no corresponding struct field, and leaves struct fields not in the JSON at their zero value. No exceptions, no errors. Pure pragmatism.
Tags beyond JSON
Tags aren’t exclusive to JSON. Libraries like pgx (PostgreSQL), yaml, xml, validate, and many others use them:
type Config struct {
Port int `yaml:"port" env:"APP_PORT" validate:"required,min=1024"`
Host string `yaml:"host" env:"APP_HOST" validate:"required"`
LogLevel string `yaml:"log_level" env:"LOG_LEVEL"`
}The key is that tags are just metadata. The compiler doesn’t validate them. If you write json:"naem" instead of json:"name", it will compile fine and you’ll have a silent bug. Tools like go vet and linters help catch these errors.
Visibility: exported vs unexported
Go has no public, private, or protected. Visibility is controlled by a rule that’s brutal in its simplicity: if it starts with an uppercase letter, it’s exported (public). If it starts with a lowercase letter, it’s unexported (private to the package).
type User struct {
ID int // exported: visible outside the package
Name string // exported
email string // NOT exported: only visible within the package
password string // NOT exported
}This applies to everything: structs, fields, functions, methods, constants, variables. It’s a convention enforced by the compiler, not by a keyword. You can’t accidentally “work around it” with reflection.
Practical implications
When you design structs that are part of a public API (a package others will import), the visibility of fields matters a lot:
// package auth
type Credentials struct {
Username string // other packages can read and write it
password string // only the auth package can access this
}
func NewCredentials(username, password string) Credentials {
return Credentials{
Username: username,
password: hashPassword(password),
}
}
func (c Credentials) ValidatePassword(input string) bool {
return checkHash(c.password, input)
}From outside the package:
creds := auth.NewCredentials("roger", "secret123")
fmt.Println(creds.Username) // works
// fmt.Println(creds.password) // compile error
creds.ValidatePassword("secret123") // worksThe password field is inaccessible from outside. You don’t need getters/setters like in Java. Encapsulation is real and the compiler guarantees it.
Visibility and JSON
A detail that catches many people: unexported fields are not serialized to JSON. If a field starts with a lowercase letter, encoding/json ignores it completely:
type Response struct {
Status string `json:"status"`
message string `json:"message"` // will NEVER appear in JSON
}This is by design. If you need a field to appear in JSON, it has to be exported. There’s no way around it with tags. It’s a simple rule that prevents accidentally exposing internal data.
Struct comparison and equality
Structs in Go are comparable if all their fields are comparable. You can use == directly:
type Point struct {
X, Y int
}
a := Point{1, 2}
b := Point{1, 2}
c := Point{3, 4}
fmt.Println(a == b) // true
fmt.Println(a == c) // falseThis works because int is comparable. But not all structs are comparable:
type Data struct {
Values []int // slices are NOT comparable
}
a := Data{Values: []int{1, 2}}
b := Data{Values: []int{1, 2}}
// fmt.Println(a == b) // compile errorSlices, maps, and functions can’t be compared with ==. If your struct contains any of these types, you need to write your own comparison function or use reflect.DeepEqual (which is slow and should be reserved for tests).
func (d Data) Equal(other Data) bool {
if len(d.Values) != len(other.Values) {
return false
}
for i, v := range d.Values {
if v != other.Values[i] {
return false
}
}
return true
}Practical rule: if you need to compare complex structs in production code, implement an
Equalmethod. If it’s just for tests,reflect.DeepEqualor libraries likego-cmpare acceptable.
Comparable structs can also be used as map keys, which is useful for caches and lookups:
type Coordinate struct {
Lat, Lng float64
}
visited := map[Coordinate]bool{
{40.4168, -3.7038}: true, // Madrid
{41.3874, 2.1686}: true, // Barcelona
}When to use structs vs maps
This question comes up a lot, especially if you come from Python or JavaScript, where dictionaries/objects are the default tool for everything.
In Go, the answer is almost always “use a struct”. But there are exceptions.
Use structs when:
- You know the shape of the data at compile time.
- The fields have different types.
- You need associated methods.
- You want compiler validation.
- The data is serialized/deserialized with a known structure.
// This is a struct. Always.
type Order struct {
ID string
Customer string
Items []OrderItem
Total float64
Status string
CreatedAt time.Time
}Use maps when:
- The shape of the data is dynamic or unknown at compile time.
- The keys are dynamic (configuration, HTTP headers, metadata).
- You’re working with generic data that has no fixed structure.
// Dynamic metadata: this is a map
metadata := map[string]string{
"source": "api",
"version": "2.1",
"region": "eu-west",
}
// HTTP headers: also a map
headers := map[string][]string{
"Content-Type": {"application/json"},
"Authorization": {"Bearer token123"},
}The anti-pattern: map[string]interface
If you find yourself writing map[string]interface{} (or map[string]any since Go 1.18) everywhere, you probably need a struct. Untyped maps throw away all compiler guarantees and turn your Go code into JavaScript with extra steps.
// DON'T do this for data with a known structure
data := map[string]any{
"name": "Roger",
"age": 32,
"email": "roger@oshy.tech",
}
name := data["name"].(string) // type assertion, can panic at runtime
// Do this instead
user := User{
Name: "Roger",
Age: 32,
Email: "roger@oshy.tech",
}
// user.Name is string. Always. No discussion.Practical example: modeling an API response
Let’s put everything together with a real example: modeling the response of an API that returns a paginated list of articles. This is the kind of code you write constantly in a Go backend.
package api
import (
"encoding/json"
"time"
)
// Article represents a blog article.
type Article struct {
ID string `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content,omitempty"`
Author Author `json:"author"`
Tags []string `json:"tags"`
PublishedAt time.Time `json:"published_at"`
Draft bool `json:"draft,omitempty"`
viewCount int // unexported: doesn't appear in JSON, not accessible outside the package
}
// Author uses composition instead of duplicating fields.
type Author struct {
Name string `json:"name"`
Email string `json:"email"`
Bio string `json:"bio,omitempty"`
}
// Pagination encapsulates pagination data.
type Pagination struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
HasNext bool `json:"has_next"`
}
// ArticleListResponse is the complete API response.
type ArticleListResponse struct {
Data []Article `json:"data"`
Pagination Pagination `json:"pagination"`
}
// NewPagination automatically calculates derived fields.
func NewPagination(page, perPage, total int) Pagination {
totalPages := total / perPage
if total%perPage != 0 {
totalPages++
}
return Pagination{
Page: page,
PerPage: perPage,
Total: total,
TotalPages: totalPages,
HasNext: page < totalPages,
}
}
// ToJSON serializes the response. Method with value receiver
// because ArticleListResponse doesn't need to be modified.
func (r ArticleListResponse) ToJSON() ([]byte, error) {
return json.MarshalIndent(r, "", " ")
}Using it:
response := api.ArticleListResponse{
Data: []api.Article{
{
ID: "1",
Title: "Structs in Go",
Slug: "go-structs",
Author: api.Author{
Name: "Roger Bosch",
Email: "roger@oshy.tech",
},
Tags: []string{"Go", "structs", "backend"},
PublishedAt: time.Now(),
},
},
Pagination: api.NewPagination(1, 10, 1),
}
data, err := response.ToJSON()
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))Result:
{
"data": [
{
"id": "1",
"title": "Structs in Go",
"slug": "go-structs",
"author": {
"name": "Roger Bosch",
"email": "roger@oshy.tech"
},
"tags": ["Go", "structs", "backend"],
"published_at": "2026-06-24T10:30:00Z"
}
],
"pagination": {
"page": 1,
"per_page": 10,
"total": 1,
"total_pages": 1,
"has_next": false
}
}This example covers all the concepts: structs with typed fields, composition (Author inside Article), JSON tags with omitempty, unexported fields (viewCount), constructor functions (NewPagination), methods with value receivers (ToJSON), and clean serialization.
Common patterns in real backend code
To close, these are patterns you’ll see (and write) constantly if you do backend in Go. They’re not theoretical: they come directly from production code.
The Options pattern for complex constructors
When a constructor has too many parameters, the functional options pattern is idiomatic in Go:
type Server struct {
host string
port int
readTimeout time.Duration
writeTimeout time.Duration
logger *slog.Logger
}
type Option func(*Server)
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
func WithTimeouts(read, write time.Duration) Option {
return func(s *Server) {
s.readTimeout = read
s.writeTimeout = write
}
}
func WithLogger(logger *slog.Logger) Option {
return func(s *Server) {
s.logger = logger
}
}
func NewServer(host string, opts ...Option) *Server {
s := &Server{
host: host,
port: 8080,
readTimeout: 5 * time.Second,
writeTimeout: 10 * time.Second,
logger: slog.Default(),
}
for _, opt := range opts {
opt(s)
}
return s
}srv := NewServer("localhost",
WithPort(9090),
WithTimeouts(10*time.Second, 30*time.Second),
)This pattern gives you sensible defaults, named optional parameters, and the ability to extend configuration without breaking the API. Libraries like google.golang.org/grpc and go.uber.org/zap use it.
Struct as service receiver
In Go backend, services and repositories are structs with dependencies injected via constructor. It’s the equivalent of a class with @Service in Spring, but without annotations or reflection:
type UserService struct {
repo UserRepository
cache Cache
logger *slog.Logger
}
func NewUserService(repo UserRepository, cache Cache, logger *slog.Logger) *UserService {
return &UserService{
repo: repo,
cache: cache,
logger: logger,
}
}
func (s *UserService) GetByID(ctx context.Context, id string) (*User, error) {
if cached, ok := s.cache.Get(ctx, "user:"+id); ok {
return cached.(*User), nil
}
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("finding user %s: %w", id, err)
}
s.cache.Set(ctx, "user:"+id, user, 5*time.Minute)
return user, nil
}Notice that UserRepository and Cache are interfaces, not concrete structs. This allows injecting different implementations in tests (mocks) and in production. Go’s rule applies: accept interfaces, return structs.
DTOs separate from domain entities
Don’t mix your domain model with your API DTOs. They’re different responsibilities:
// Domain entity (internal layer)
type Task struct {
ID string
Title string
Description string
Status TaskStatus
AssigneeID string
CreatedAt time.Time
UpdatedAt time.Time
}
// Response DTO (API layer)
type TaskResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
Assignee string `json:"assignee,omitempty"`
CreatedAt string `json:"created_at"`
}
// Explicit conversion
func ToTaskResponse(t Task) TaskResponse {
return TaskResponse{
ID: t.ID,
Title: t.Title,
Description: t.Description,
Status: string(t.Status),
Assignee: t.AssigneeID,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
}
}It’s more code than putting JSON tags directly on the entity. But when your API needs to return data differently from how you store it (and this always happens), you’ll have the separation ready. I explain this in more detail in project structure and clean architecture in Go.
Structs as typed configuration
Instead of reading environment variables as strings scattered throughout the code, centralize configuration in a struct:
type Config struct {
Server ServerConfig
Database DatabaseConfig
Redis RedisConfig
}
type ServerConfig struct {
Host string `env:"SERVER_HOST" envDefault:"0.0.0.0"`
Port int `env:"SERVER_PORT" envDefault:"8080"`
}
type DatabaseConfig struct {
URL string `env:"DATABASE_URL,required"`
MaxConns int `env:"DB_MAX_CONNS" envDefault:"25"`
ConnMaxLifetime time.Duration `env:"DB_CONN_MAX_LIFETIME" envDefault:"5m"`
}
type RedisConfig struct {
Addr string `env:"REDIS_ADDR" envDefault:"localhost:6379"`
Password string `env:"REDIS_PASSWORD"`
DB int `env:"REDIS_DB" envDefault:"0"`
}With a library like github.com/caarlos0/env you can populate this struct directly from environment variables. Result: typed configuration, with defaults, with validation, and without a single bare os.Getenv in your code.
Less ceremony, more clarity
Structs in Go are simple by design. No inheritance, no magic constructors, no granular visibility with five keywords. And that’s precisely what makes them powerful: they force you to model your data explicitly, to compose instead of inherit, to be clear instead of abstract.
If you come from languages with classical object orientation, the change can feel like a step backward. But after using Go in production for a while, you realize that most OOP abstractions you used weren’t necessary. They were ceremonial. Go eliminates the ceremony and leaves you with what matters: data, behavior, and composition.
Structs are the foundation. On them you build services, API handlers, repositories, configuration, DTOs. For the next step, understand how interfaces in Go complement structs, and how together they form the type system that makes Go what it is. And when you’re ready to build something real, a REST API with Go is the best proving ground.


