The blank form and the filled form
You are building a service that processes user profiles. The JSON payload arrives, but before you can decode it, you need a place to put the data. You also need a default profile for testing, and you need a pointer to a profile to pass through a chain of functions. Go gives you three tools for creating structs: literals, new, and var. They differ in what they return, where the memory lives, and how much control you have over the initial state.
Pick the right tool and your code stays readable. Pick the wrong one and you end up fighting nil pointers or copying data you didn't mean to copy.
Structs are values, not references
A Go struct is a value type. It holds its data directly. When you assign a struct to a variable, the compiler copies the entire struct. This behavior is different from classes in languages like Java or Python, where variables hold references to objects on the heap.
Think of a struct like a paper form. The form has fields. You can fill out the form immediately with a pen. That's a literal. You can also have a blank form sitting on your desk. That's a zero value. If you photocopy the blank form and hand the copy to a colleague, they have their own blank form. Changes they make don't affect your copy. That's value semantics.
If you need multiple people to edit the same form, you hand them a pointer to the original. In Go, that's a *Struct. The pointer holds the memory address. Everyone who has the pointer looks at the same struct.
Go's initialization tools let you choose between the form and the pointer, and between blank and filled.
The three ways to create a struct
Here are the three patterns side by side. Each produces a struct, but the result and the memory layout differ.
package main
import "fmt"
type Config struct {
Host string
Port int
}
func main() {
// Literal creates a value with specific fields.
// Named fields make the intent clear and allow partial initialization.
c1 := Config{Host: "localhost", Port: 8080}
// var declares a variable; the compiler allocates space and zeros it.
// c2 is a value type with default values.
var c2 Config
// new allocates on the heap and returns a pointer to the zeroed struct.
// c3 is a *Config pointing to a zero value.
c3 := new(Config)
fmt.Printf("%+v\n", c1)
fmt.Printf("%+v\n", c2)
fmt.Printf("%+v\n", *c3)
}
The literal Config{Host: "localhost", Port: 8080} creates a value. c1 holds the data directly. The var c2 Config declaration creates a zero value. c2.Host is "" and c2.Port is 0. The new(Config) call allocates memory on the heap and returns a pointer. c3 is a *Config. Dereferencing *c3 gives you the zero value.
What happens under the hood
When you use a literal, the compiler checks that every field name exists and that every value matches the field type. If the check passes, the compiler generates code to allocate space and set the fields. For small structs, this allocation usually happens on the stack. The stack is fast and automatic. The memory vanishes when the function returns.
When you use var, the compiler reserves space for the struct and zeros the memory. Zeroing is a hardware-level operation that happens instantly. The struct is ready to use immediately. You can read fields safely. You can pass the struct to functions. The zero value is a valid state.
When you use new, the runtime allocates memory on the heap. The heap is managed by the garbage collector. new returns a pointer. The pointer itself is small, but the struct it points to lives on the heap. Use new when you need a pointer and you don't have the field values yet.
Go has a convention for receiver naming. If you add a method to Config, the receiver name should be short and match the type. Use (c *Config) or (c Config). Don't use (this *Config) or (self *Config). The community expects one or two letters.
Zero values are safe by design
Go's zero values are engineered to be safe. A zero value is never a crash waiting to happen. An int zero value is 0. A string zero value is "". A bool zero value is false. A slice zero value is nil, and you can append to a nil slice without panicking. A map zero value is nil, but writing to a nil map panics, so maps are the exception.
This design means you can declare a struct with var and start using it immediately. You don't need to check if it's nil. You don't need to initialize fields before reading them. The defaults are sensible.
package main
import "fmt"
type Counter struct {
Total int
Tags []string
}
func main() {
// var creates a zero value; Total is 0 and Tags is nil.
var c Counter
// Appending to a nil slice works; the slice grows automatically.
c.Tags = append(c.Tags, "init")
// Reading Total is safe; it returns 0.
fmt.Println(c.Total, c.Tags)
}
The append call handles the nil slice gracefully. It allocates a new underlying array and returns a non-nil slice. The zero value acts as a starting point that requires no boilerplate.
Real world: unmarshaling JSON
In production code, you often initialize a struct to receive data from an external source. JSON decoding is the classic example. The decoder needs a pointer to a struct so it can write the parsed data into the fields.
package main
import (
"encoding/json"
"net/http"
)
type Request struct {
Action string `json:"action"`
Value int `json:"value"`
}
// HandleRequest processes an incoming JSON payload.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
// var creates a zero value; json.Unmarshal requires a pointer to a non-nil value.
var req Request
// Decode writes into the struct fields; zero values act as safe defaults.
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Error handling follows the idiomatic pattern; the unhappy path is explicit.
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Use the populated struct.
// req.Action is safe to read even if the JSON was empty.
_ = req.Action
}
The var req Request declaration creates a zero value on the stack. The &req expression takes the address. The decoder writes into the struct. If the JSON is missing a field, the struct keeps the zero value. The code works without special cases.
The if err != nil block is verbose by design. The community accepts the boilerplate because it makes the error path visible. You can't accidentally swallow an error.
Pitfalls and compiler errors
Initialization errors usually fall into three categories: unexported fields, type mismatches, and pointer confusion.
Unexported fields cannot be set from outside the package. If you define a struct with a lowercase field, you can't use a literal to set it.
package main
type Secret struct {
key string // Unexported field
}
func main() {
// This fails because key is unexported.
// The compiler rejects this with:
// cannot refer to unexported field or method key in struct literal of type Secret
s := Secret{key: "123"}
}
The fix is to use a constructor function or a method to set the field. The constructor lives in the same package where the struct is defined, so it can access unexported fields. This leads to the convention "accept interfaces, return structs." If a type has invariants, return a pointer from a constructor and let the caller use the interface.
Type mismatches are straightforward. The compiler checks every field value against the field type.
package main
type Point struct {
X int
}
func main() {
// This fails because "10" is a string, not an int.
// The compiler rejects this with:
// cannot use "10" (untyped string constant) as int value in struct literal
p := Point{X: "10"}
}
Pointer confusion happens when you mix up values and pointers. new returns a pointer. If you pass a pointer to a function that expects a value, the compiler complains.
package main
type Data struct {
Value int
}
// Process expects a value, not a pointer.
func Process(d Data) {
_ = d.Value
}
func main() {
// new returns *Data.
p := new(Data)
// This fails because p is *Data, not Data.
// The compiler rejects this with:
// cannot use p (variable of type *Data) as Data value in argument
Process(p)
}
The fix is to dereference the pointer: Process(*p). Or change the function signature to accept a pointer. Go has a helpful shortcut: if you call a pointer-receiver method on a value, Go takes the address automatically. If you call a value-receiver method on a pointer, Go dereferences automatically. The compiler handles the conversion when it's safe.
Go also has a convention about *string. Strings are cheap to pass by value. They are immutable and small. Don't use *string unless you need to distinguish between a missing value and an empty string. Passing a pointer to a string adds indirection without benefit.
Choosing the right initialization
The choice depends on what you need: a value or a pointer, and whether you have the data now or later.
Use a struct literal when you have the field values and want a value type. Literals are explicit, safe, and easy to read. They work for small structs and for passing data to functions that take values.
Use &Struct{} when you need a pointer and want to initialize fields immediately. This is the most common pattern in idiomatic Go. The address-of operator on a literal is clear and flexible. The compiler performs escape analysis and may keep the struct on the stack if it doesn't escape the function.
Use var s Type when you need a zero value and plan to populate fields later. This is ideal for decoding data, accumulating results, or creating a default instance. The zero value is safe and ready to use.
Use new(Type) when you need a pointer to a zero value and will populate it via methods or external input. new is rarely used in modern Go code. &Struct{} is preferred because it lets you set fields at creation time. new is mostly useful for arrays and slices, or when you explicitly want heap allocation without initialization.
Prefer &Struct{} over new. It's clearer, more flexible, and works with escape analysis. Trust the compiler to decide where the memory lives.