Practical Introduction
A production service is built from small decisions. One of the first decisions is also one of the easiest to underestimate: which type should hold this value?
In Go, types are not just labels for the compiler. They communicate business intent, influence memory usage, prevent invalid states, and make integration with other systems more predictable. A delivery dispatch service, for example, may need to handle courier IDs, parcel fees, package weights, feature flags, approval states, binary scanner messages, and collections of active deliveries. Each value has different rules.
The practical goal is simple: model data so that the compiler helps you, future readers understand the code quickly, and the runtime avoids unnecessary work.
Context and Scope
Imagine a small Go service that assigns parcels to couriers. The system has a few moving parts:
HTTP API (application programming interface) request
|
v
Input decoder
|
v
Validation and assignment service
|
v
In-memory indexes and storage adapter
|
v
Response or background processing
The service receives inputs such as parcel IDs, package weight, delivery fee, courier capabilities, and operational status. It must produce clear decisions: accept the parcel, reject invalid input, mark a courier as unavailable, or store data for later processing.
This is where Go's core language elements matter. The difference between int64 and float64, bool and *bool, []byte and string, or map[string]bool and map[string]struct{} can affect correctness, performance, and maintainability.
Start with Domain Meaning, Then Pick the Type
The first mistake many developers make is choosing types mechanically. They use int64 for every number, string for every identifier, and float64 for anything that looks numeric. That works for a toy program, but it creates weak contracts in a larger code base.
A better workflow is:
- Decide what the value represents.
- Decide whether negative values are valid.
- Decide whether the value must be exact or approximate.
- Decide whether the value crosses a boundary such as an API, file format, or protocol.
- Decide whether absence is different from a zero value.
Here is a compact model for a delivery service:
package dispatch
type CourierID string
type ParcelID string
type DeliveryStatus uint8
const (
StatusUnknown DeliveryStatus = iota
StatusWaiting
StatusAssigned
StatusDelivered
)
type Parcel struct {
ID ParcelID
WeightKg float64
FeeCents int64
Fragile bool
Status DeliveryStatus
}
type Courier struct {
ID CourierID
MaxParcels uint16
CapacityKg float64
InService bool
}
A few choices are intentional:
ParcelIDandCourierIDare named string types, so the code does not casually mix unrelated IDs.FeeCentsusesint64, because money should be stored in the smallest indivisible unit rather than as a floating-point number.WeightKgusesfloat64, because measurement values can tolerate small representation differences.DeliveryStatususes a small unsigned integer type, because the valid states are compact and non-negative.InServiceusesbool, because it has exactly two states.
Choosing Numeric Types Without Guesswork
Go gives you several integer types. That is useful, but it also means you must choose deliberately.
| Need | Practical Go choice | Reason |
|---|---|---|
| General counters and loop indexes | int |
Natural default for everyday counting |
| External fixed-width values | int32, int64, uint16, uint32 |
Matches storage formats, protocols, or APIs |
| Values that must not be negative | uint, uint16, uint32 |
Makes invalid negative values impossible at the type level |
| Money in cents or smallest units | int64 |
Keeps arithmetic exact |
| Measurements and averages | float64 |
Good default for real-number calculations |
| Raw binary data | []byte or byte |
byte clearly means raw data, not a business number |
| Unicode code points | rune |
Communicates character-level processing |
Use int for routine counting. Use fixed-width types when data must match a known external representation. Use smaller types only when the memory saving matters, such as very large in-memory datasets or compact event formats.
Be careful with unsigned types. They are useful when negative values are invalid, but mixing signed and unsigned values often forces explicit conversions. That explicitness is part of Go's safety model, but it also means you should avoid unsigned numbers unless they express a real rule.
Floats Are for Measurements, Not Exact Accounting
Floating-point values are convenient for speed, temperature, fuel usage, distance, and similar measurements. They are not suitable for money, inventory counts, or any value where every unit must be exact.
Use integer arithmetic for exact quantities:
package main
import "fmt"
func main() {
feeCents := int64(1299)
discountCents := int64(250)
finalCents := feeCents - discountCents
fmt.Println("final fee in cents:", finalCents)
averageWeightKg := 7.45
fmt.Println("average package weight:", averageWeightKg)
}
1299 cents is exact. 12.99 as a floating-point value is not guaranteed to behave like decimal money across repeated calculations. This matters when a system processes many transactions or generates reports from accumulated values.
Booleans, Three-State Logic, and Constants
A bool should represent a true yes-or-no state. Go does not treat 0, 1, empty strings, or non-empty strings as booleans. A condition must be explicitly true or false, which keeps decision logic readable.
Sometimes two states are not enough. A courier may have accepted a delivery, rejected it, or not responded yet. A plain bool cannot represent that third state.
Use *bool when absence has business meaning:
package dispatch
func AcceptedText(accepted *bool) string {
if accepted == nil {
return "not answered"
}
if *accepted {
return "accepted"
}
return "rejected"
}
This is clear at API boundaries and form-like inputs. For high-volume internal data, a small integer enum may be more compact:
package dispatch
type ReplyState int8
const (
ReplyUnknown ReplyState = iota
ReplyAccepted
ReplyRejected
)
iota is useful for related constants. It starts at zero inside a constant block and increments on each line. Use it for status values, categories, and compact internal vocabularies. Be careful when these values are stored outside the process. Reordering constants later can silently change the meaning of old data.
Bit Flags for Compact Feature Sets
When a value can have several independent yes-or-no capabilities, bit flags can be more compact than many booleans. Each flag uses one bit inside an integer.
package dispatch
type CourierFeature uint8
const (
FeatureColdBox CourierFeature = 1 << iota
FeatureHeavyLoad
FeatureSignaturePad
)
func HasFeature(mask, feature CourierFeature) bool {
return mask&feature != 0
}
func ExampleFeatures() bool {
features := FeatureColdBox | FeatureSignaturePad
return HasFeature(features, FeatureSignaturePad)
}
The | operator combines flags. The & operator checks whether a flag is present. This is efficient for permissions, device capabilities, and feature toggles, but document the meaning of each flag. Bitwise code is fast, but unclear flags are hard to maintain.
Variables, Zero Values, and Shadowing
Go variables are always initialized. If you do not provide a value, Go assigns the zero value:
- Numbers become
0. - Booleans become
false. - Strings become an empty string.
- Pointers, slices, maps, channels, functions, and interfaces become
nil.
This removes a class of uninitialized-variable bugs, but it does not remove the need to model meaning. An empty string may mean missing input, or it may be valid. A nil slice may mean not loaded yet, or it may mean an empty collection. Decide what the zero value means for each type.
Use var when you need declaration without immediate initialization or when declaring at package scope. Use := inside functions when declaring and assigning a new local variable.
Watch for shadowing:
package dispatch
func UpdateStatus(current DeliveryStatus, input string) DeliveryStatus {
if input == "delivered" {
current := StatusDelivered
return current
}
return current
}
Inside the if, current := StatusDelivered creates a new local variable instead of updating the outer one. In this tiny example, returning it immediately is harmless. In longer functions, accidental shadowing can create bugs that are difficult to see during code review. Use = when you mean to update an existing variable.
Keep variables in the smallest practical scope. Shorter scope makes code easier to read and can reduce how long memory must stay alive.
Pointers: Use Them for Sharing, Mutation, and Optional Values
A pointer stores the address of another value. The & operator gets an address. The * operator dereferences a pointer to read or update the value at that address.
package dispatch
type DeliveryPlan struct {
ParcelID ParcelID
Courier CourierID
Notes [512]byte
Status DeliveryStatus
}
func MarkAssigned(plan *DeliveryPlan, courier CourierID) {
if plan == nil {
return
}
plan.Courier = courier
plan.Status = StatusAssigned
}
Passing *DeliveryPlan avoids copying a larger struct and allows the function to change the original value. This is appropriate when a value is identity-like, large, or intentionally mutable.
Prefer values when the type is small and you do not need mutation. Copying a small struct is often clearer than passing a pointer everywhere. Pointers add indirection, can increase heap allocation, and require nil checks in production code.
Good pointer use cases:
- Mutating a struct in a function or method.
- Avoiding repeated copies of large structs.
- Representing optional values with
nil. - Sharing one object across layers intentionally.
Weak pointer use cases:
- Passing every primitive as
*intor*boolwithout needing nil. - Using pointers to look clever.
- Avoiding copies before measuring whether copies are a problem.
Strings and Bytes
A Go string is immutable text stored as UTF-8 bytes. Copying a string variable is cheap because the value behaves like a small header pointing to underlying bytes. Modifying a string is not possible in place. Building a new string creates new data.
Use string for human-readable text. Use []byte for raw data, network buffers, file contents, or scanner packets. Converting between them copies data, so avoid unnecessary conversions in hot paths.
For repeated string building, use strings.Builder rather than repeated concatenation in a loop.
package dispatch
import "strings"
func BuildLabel(parts []string) string {
var b strings.Builder
for i, part := range parts {
if i > 0 {
b.WriteString("-")
}
b.WriteString(part)
}
return b.String()
}
When processing user-facing text, remember that a byte is not always a character. Use rune when you need Unicode code points. Use byte-level loops only for binary work or low-level performance-sensitive processing.
Slices, Arrays, and Maps
Arrays in Go have fixed length, and the length is part of the type. Assigning an array copies the whole value. Arrays are useful for fixed-size data such as protocol fields, hash-like values, or compact buffers.
Most application code uses slices. A slice is a view over an array. It has a pointer, length, and capacity. Because slices share backing arrays, changing one slice can affect another slice that views the same data.
package dispatch
func FirstBatch(parcels []Parcel, n int) []Parcel {
if n > len(parcels) {
n = len(parcels)
}
batch := make([]Parcel, n)
copy(batch, parcels[:n])
return batch
}
This copies the selected parcels into a new slice. Copying is useful when the returned data must not keep a large backing array alive or must be isolated from later mutation.
Always assign the result of append:
parcels = append(parcels, Parcel{ID: "P-1001", FeeCents: 850})
append may reuse the existing backing array or allocate a new one. The returned slice is the updated slice header, so ignoring it is a bug.
Maps are Go's built-in hash tables. They are excellent for lookups and grouping, but they have important rules:
- Use the comma-ok form when a zero value could be ambiguous.
- Do not rely on iteration order.
- Preallocate when you know the approximate size.
- Do not write to the same map from multiple goroutines without synchronization.
package dispatch
func IndexByParcelID(parcels []Parcel) map[ParcelID]Parcel {
index := make(map[ParcelID]Parcel, len(parcels))
for _, p := range parcels {
index[p.ID] = p
}
return index
}
func UniqueDepotCodes(codes []string) map[string]struct{} {
set := make(map[string]struct{}, len(codes))
for _, code := range codes {
set[code] = struct{}{}
}
return set
}
map[string]struct{} is the idiomatic set pattern when only presence matters. A struct{} value carries no payload, which makes the intent clearer than map[string]bool when you do not need a true-or-false value per key.
Explicit Type Conversion
Go does not silently convert between types. This is deliberate. A conversion can lose precision, truncate data, copy memory, or reinterpret a value. Go requires you to write that decision at the call site.
package main
import "fmt"
func main() {
var count32 int32 = 120
count64 := int64(count32)
weight := 12.95
wholeKg := int(weight)
packet := []byte("PICK")
text := string(packet)
copyArray := [4]byte(packet)
sharedArray := (*[4]byte)(packet)
sharedArray[0] = 'T'
fmt.Println(count64, wholeKg, text, copyArray, string(packet))
}
The int(weight) conversion removes the fractional part. The string(packet) conversion copies bytes into a string. The slice-to-array value conversion copies into a new array, while the slice-to-array pointer conversion shares the backing data. Use the pointer form only when you intentionally want shared memory and you can control the lifetime of that data.
A Practical Workflow for New Code
When adding a new field, function, or collection, use this workflow:
- Name the domain rule first. For example: delivery fee, courier capacity, approval response, scanner bytes.
- Choose the smallest type that is still safe and clear.
- Decide whether the zero value is valid.
- Use constants for fixed states and repeated meanings.
- Keep variables local unless they truly need wider scope.
- Prefer values for small immutable data and pointers for large or mutable data.
- Preallocate slices and maps when the expected size is known.
- Copy slices or byte buffers when isolation matters.
- Make every conversion explicit and intentional.
- Add a focused test or benchmark when memory or speed is part of the reason.
Useful commands when validating performance-sensitive choices:
go test -bench=. -benchmem
go build -gcflags="-m" ./...
Benchmarks help compare allocation patterns. Escape analysis output helps reveal when values move to the heap. Do not optimize blindly, but do measure when a type decision is based on performance.
Common Mistakes to Watch For
- Using floating-point values for money or exact counts.
- Choosing
int64for everything without considering memory volume. - Using
boolwhen the domain has three or more states. - Reordering
iotaconstants after values have been stored externally. - Accidentally shadowing variables with
:=. - Passing small values by pointer without a clear reason.
- Ignoring the returned slice from
append. - Holding a small sub-slice of a large buffer and keeping the whole buffer alive.
- Treating map iteration order as stable.
- Writing to a map concurrently without protection.
- Converting between
stringand[]byterepeatedly in a hot path.
Checklist
Before merging Go code that introduces new types or collections, check the following:
- Does each field type express the business rule clearly?
- Are exact values stored as integers rather than floats?
- Are measurements using
float64unless there is a clear reason forfloat32? - Are optional fields modeled explicitly with pointers or small enums?
- Are constants used instead of magic numbers and unclear strings?
- Are variable scopes as small as practical?
- Are pointers used only for mutation, sharing, large structs, or optional state?
- Are slices and maps preallocated when size is predictable?
- Is copied data copied intentionally, especially around slices, strings, and byte buffers?
- Are type conversions visible and reviewed for truncation, copy cost, or shared memory?
Conclusion
Go's core language elements are small, but they shape the behavior of the whole service. Good type choices make invalid states harder to represent. Good variable scope reduces confusion and unnecessary lifetime. Good pointer use balances clarity with performance. Good collection handling prevents hidden memory retention and unstable behavior.
Treat types, variables, operators, and collections as design tools, not syntax trivia. When you model data carefully at the beginning, the rest of the system becomes easier to test, review, and maintain.