Skip to main content

Command Palette

Search for a command to run...

Learning Go, Week 4: Functions, Methods, Defer, and File Handling

Updated
7 min readView as Markdown
Learning Go, Week 4: Functions, Methods, Defer, and File Handling
A
I’m a curious learner and aspiring software developer who enjoys building real-world systems, exploring new technologies, and learning in public. I write about coding, personal growth, and turning ideas into practical systems—while simplifying complex concepts for others.

Last week was maps, control flow, and structs. This week I moved from organizing data to doing things with it — functions (including the variadic kind), methods on structs, the defer keyword, and basic file I/O. Four topics, but they build on each other more than I expected: structs from last week become the receivers for this week's methods, and defer turns out to be the idiomatic glue for the file operations at the end.

Functions, and the Variadic Kind

Regular functions in Go look about how you'd expect coming from any typed language — explicit parameter types, explicit return type:

func add(x int, y int) int {
	return x + y
}

func subtract(x int, y int) int {
	return x - y
}

Nothing surprising there. What I hadn't used yet was a variadic function — one that accepts any number of arguments of the same type using ...:

func proAdd(values ...int) int {
	total := 0

	for _, value := range values {
		total += value
	}

	return total
}

Inside the function, values behaves like a regular []int slice — you range over it the same way you'd range over any slice. The interesting part is calling it: you can either pass individual arguments, or explode an existing slice into the call with the same ... syntax:

values := make([]int, 0, 101)

for i := 0; i <= 100; i++ {
	values = append(values, i)
}

result := proAdd(values...)

That last line — proAdd(values...) — is the same operator on both ends: ... collects into a slice on the function's side, and spreads a slice back out on the caller's side. It's a small piece of syntax doing two related but opposite jobs depending on which side you're on, which took a second to click.

Methods: Attaching Behavior to Structs

Last week's structs only held data. Methods are how Go attaches behavior to that data without needing classes. A method is just a function with an extra piece before the name — the receiver — that ties it to a specific type:

type User struct {
	Name   string
	Email  string
	Status bool
	Age    int
}

func (u User) GetStatus() bool {
	return u.Status
}

u is the receiver, User is the type it's attached to, and inside the method u gives you access to that instance's fields — u.Status, u.Email, u.Name, same dot notation as always.

The part I had to sit with is that there are two kinds of receivers, and the difference actually matters:

  • Value receiverfunc (u User) ... — the method gets a copy of the struct. Anything it changes on u doesn't touch the original.

  • Pointer receiverfunc (u *User) ... — the method gets a pointer to the original struct, so changes made inside the method persist after it returns.

Given last week's deep-dive into pointers, this mapped cleanly onto something I already understood: if a method needs to modify the struct (updating an email, incrementing an age), it needs a pointer receiver, or the change just evaporates on a copy. If it's only reading data (like GetStatus), a value receiver is fine and arguably clearer about intent. The convention I've seen recommended — and the one I'm going with — is to be consistent across a type's methods rather than mixing receiver kinds arbitrarily.

Defer: Delay Until the Function Returns

defer schedules a function call to run right before the surrounding function returns, instead of running it immediately where it's written:

func myDferFunc() {
	for i := 0; i < 5; i++ {
		defer fmt.Println(i)
	}
}

Normal statements keep executing past a defer line — the deferred call just gets registered and parked. What surprised me is the order things come back out in: LIFO, last in first out. The loop above registers 0, 1, 2, 3, 4 in that order, but when myDferFunc returns, they print 4, 3, 2, 1, 0. Same rule applies to any number of defer statements stacked in a function — the most recently registered one fires first, like a stack unwinding.

This clicked once I stopped thinking of defer as "run this later" and started thinking of it as "push this onto a stack that unwinds on the way out." Every function manages its own defer stack, and it only unwinds when that specific function returns — deferred calls inside a nested function fire before control ever gets back to the caller.

The practical reason this exists, and the reason I'll actually use it starting this week, is cleanup: closing files, closing DB connections, unlocking mutexes. It guarantees the cleanup runs even if a function has multiple return points, without having to duplicate the cleanup call before every single return.

File Handling

This is where defer actually earns its keep. Creating and writing a file:

file, err := os.Create("firstfile.txt")
checknillerr(err)

length, err := io.WriteString(file, content)
checknillerr(err)

fmt.Println("Wrote", length, "characters to file.")

file.Close()

os.Create creates the file if it doesn't exist, or truncates and overwrites it if it does, and hands back a file object plus an error. io.WriteString writes a string into that file object and returns the byte count written. In production code, the closing call is usually written as defer file.Close() immediately after the file is successfully created — that way the close is guaranteed to happen when the function returns, instead of depending on execution reaching a file.Close() line at the bottom, which might get skipped entirely if the function returns early on an error somewhere in between.

Reading a file back is the mirror image:

databytes, err := ioutil.ReadFile(filename)
checknillerr(err)

fmt.Println("File read successfully.", string(databytes))

ioutil.ReadFile reads the whole file into memory as a []byte, which then gets converted to a string for printing. Worth flagging: ioutil.ReadFile is deprecated in modern Go — os.ReadFile is the current equivalent and does the same job without the extra import.

Error handling throughout follows the same pattern from earlier weeks — every operation that can fail returns an error alongside its result, and I'm using a small helper to panic on anything non-nil rather than repeating the if err != nil block everywhere:

func checknillerr(err error) {
	if err != nil {
		panic(err)
	}
}

What I Learned This Week

  • Regular functions look like any typed language; variadic functions (...T) accept any number of arguments and behave like a slice inside the function body.

  • The same ... syntax spreads a slice into a variadic call on the caller's side — one operator, two directions depending on context.

  • Methods attach behavior to a type via a receiver written between func and the method name.

  • Value receivers operate on a copy; pointer receivers operate on the original — mutation requires a pointer receiver.

  • defer delays a call until the surrounding function returns, and multiple deferred calls unwind in LIFO order.

  • Each function manages its own defer stack independently — nested functions unwind their own defers before returning control to the caller.

  • defer is idiomatically used for cleanup — closing files, unlocking mutexes, closing connections — because it guarantees the cleanup runs regardless of how many return points a function has.

  • os.Create creates or truncates a file and returns a file object plus an error; io.WriteString writes into it.

  • ioutil.ReadFile reads a full file as []byte; the modern equivalent is os.ReadFile.

  • Every I/O operation returns an error that should be checked — a pattern that's been consistent since week one.

Functions and methods felt like a natural extension of last week's structs — once you have data grouped into a type, attaching behavior to it is a small conceptual step. defer was the real "aha" of the week: it's a genuinely different mental model from anything JS has, and understanding it as a per-function LIFO stack rather than "runs at the end" made file handling immediately make more sense, since the deferred close pattern only works because of that guarantee.

Code and notes for this post live in my GitHub repository, organized topic-wise: Go Learning — Architecture Lab

Learning Go

Part 1 of 4

This series covers Go's syntax, types, memory management, pointers, slices, structs, interfaces, concurrency, goroutines, channels, error handling, packages, networking, and other core concepts.

Up next

Learning Go, Week 3: Maps, Control Flow, and Structs

Maps, if/else, switch, and why Go skips classes entirely