1. A resource in Go: any “acquire → release” scenario
When people say “resource”, a beginner’s brain usually pictures a file on disk. But in practice, a resource is anything with an ownership contract: you “acquired” something, and until you “release” it, it is considered taken, locked, or consuming a limited quota. And if you forget to release it, the system may start behaving strangely, slowly, or just crash at the worst possible moment (usually right before the deadline).
In Go, resources often include file descriptors (you need Close()), network connections (also Close()), locks (Unlock()), and sometimes even “temporary rental” of something in memory. The core idea is the same: getting a resource is easy, but you must close/release it reliably, even if in the middle of a function you do a return because of an error.
Here defer acts like a “sticky note on your monitor”: “Don’t forget to turn off the lights when you leave.”
2. The basic protocol: acquire → check err → schedule defer
The main beginner trap is that defer looks like “something I’ll write later”. But the right habit is the opposite: as soon as you successfully acquire a resource, immediately put a defer to release it—while you still remember the resource even exists.
There is an almost ritual sequence:
- acquire the resource and the error
- if there is an error, exit
- if it is a success, schedule defer to release it
- continue the main work
A mini-skeleton (abstract, just so it “clicks” in your head):
package main
import "fmt"
func doSomething() (err error) {
res, err := acquire()
if err != nil {
return err
}
defer release(res)
fmt.Println("work with resource")
return nil
}
func acquire() (int, error) { return 1, nil }
func release(_ int) {}
func main() {
_ = doSomething()
}
Notice an important psychological point: defer is set not at the end, but “right next to where the resource appeared”.
3. Close: why forgetting to close is a real problem
Files, connections, and other “close-able” things in Go usually give you a method Close() error. Even if you have not gone deep into the file system yet, we already need the principle: open it—close it.
I’ll show two variants: “fragile” and “normal”.
Fragile (a learning example—don’t copy this into real life):
package main
import (
"fmt"
"os"
)
func readFirstByte(path string) (byte, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
var buf [1]byte
_, err = f.Read(buf[:])
f.Close() // if we return earlier, the file won't be closed
if err != nil {
return 0, err
}
return buf[0], nil
}
func main() {
b, err := readFirstByte("data.txt")
fmt.Println(b, err)
}
Normal (closing is guaranteed on any exit from the function):
package main
import (
"fmt"
"os"
)
func readFirstByte(path string) (byte, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
var buf [1]byte
_, err = f.Read(buf[:])
if err != nil {
return 0, err
}
return buf[0], nil
}
func main() {
b, err := readFirstByte("data.txt")
fmt.Println(b, err)
}
The mental model here is very simple: defer f.Close() turns “I have to remember to close it” into “it will close itself when exiting”.
4. Unlock: the same kind of resource, but about “access”
When you see Lock(), think: “I took exclusive access.” Until you call Unlock(), other parts of the program (or other goroutines, but we won’t go deep into that yet) may be waiting. Even if you are not writing concurrent code yet, the Lock/Unlock pattern is useful to understand as an example of a resource you must not forget to release.
The classic pattern looks like this:
mu.Lock()
defer mu.Unlock()
Let’s make a minimal example. There is no concurrency here, but the pattern reads well:
package main
import (
"fmt"
"sync"
)
var mu sync.Mutex
var counter int
func incSafely() {
mu.Lock()
defer mu.Unlock()
counter++
fmt.Println("counter =", counter) // counter = 1 (then 2, 3...)
}
func main() {
incSafely()
incSafely()
}
Why is defer especially good right here? Because add 3 checks in the middle of the function, 2 early returns, and the chance of forgetting Unlock() increases sharply. defer removes that risk.
5. Cleanup errors: what to do if Close returns an error
Now for the most interesting (and most real-world) part: sometimes releasing a resource can itself fail.
mu.Unlock() returns no errors—nothing to discuss there. But Close() very often returns an error. And this is not a “formality”: for example, when writing data, an error may surface exactly at close time (the buffer is flushed, the system finally says “oops, no space left”, and hello).
The problem: defer f.Close() looks nice, but… where do you put the error from Close()?
There are several strategies, and it is important to understand their meaning rather than memorize a “magic line”.
A simple strategy: ignore the Close error
Sometimes this is acceptable. For example, you were reading a file (not writing), and closing almost never matters for the “correctness” of the result. Then you can honestly do this:
defer func() { _ = f.Close() }()
But note: this is a conscious decision. Not “I didn’t know what to do, so _ =”.
A practical strategy: if there is no main error, return the Close error
That is, Close() becomes the “default error”, but it does not overwrite the main one.
For this, it is convenient to use a named err, because defer can modify the named result right before returning.
Example:
package main
import (
"fmt"
"os"
)
func readSomething(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
// imagine there is work here that can return an error
return nil
}
func main() {
fmt.Println(readSomething("data.txt"))
}
The idea: if everything was fine, but closing failed, we want to report it.
The “honest” strategy: return both errors via errors.Join
If you already have an error from the main work, and Close() also failed, then the choice “which error to keep” is always a bit sad. errors.Join lets you avoid choosing: return both. This is very Go-like: we do not sweep problems under the rug.
package main
import (
"errors"
"fmt"
"os"
)
func doWork(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
}()
// Let the main work "fail"
return errors.New("work failed")
}
func main() {
fmt.Println(doWork("data.txt"))
}
Here errors.Join(err, cerr) neatly handles even the case where err == nil (then it returns just cerr). This is convenient: you do not have to write extra ifs.
How to choose a strategy: a quick guide
When in a real project you decide “what to do with the Close error”, it helps to keep not a “recipe” but a criterion in mind. Here is a compact reference table.
| Situation | What we usually do | Why |
|---|---|---|
| You read data, the result is already obtained, Close() hardly affects anything | Sometimes we ignore Close() | Close errors rarely matter for read logic |
| You wrote data, and the success of the operation matters | You must not lose the Close() error | The error may mean the data was not actually saved |
| There is a main-work error and there is a Close() error | Often errors.Join | So you don’t have to “choose which one to lose” |
Why deferred cleanup should be “short and boring”
Sometimes you want to do half the program inside a defer: logging, retries, “and let’s also check this…”. Don’t.
defer is good as a small guarantee: close, unlock, release. The more complex the cleanup is, the higher the chance that it will start failing unpredictably itself, and you’ll get “an error while handling an error while closing the handling of an error”.
A good habit: deferred code should be short, clear, and not change external state unless absolutely necessary. If you need a lot of logic, it is better to move it into a separate function and call it from defer (and still keep it simple).
6. Defer in a loop: “not a bug, but a trap”
A very typical surprise: someone writes defer inside a loop and thinks the closing will happen “at the end of the iteration”. No: it will happen at the end of the function.
See:
package main
import "fmt"
func demo() {
for i := 0; i < 3; i++ {
defer fmt.Println("close resource", i)
fmt.Println("work", i)
}
}
func main() {
demo()
}
// work 0
// work 1
// work 2
// close resource 2
// close resource 1
// close resource 0
This is sometimes fine (for example, you really do want to close everything at the end). But if you open many resources in a loop, then defer inside the loop can lead to keeping resources open for too long.
If you need to “close at the end of each iteration”, usually you do this: move the iteration body into a separate function and put defer inside it—then the “end of the iteration” matches “exiting the function”.
package main
import "fmt"
func oneIteration(i int) {
defer fmt.Println("cleanup", i)
fmt.Println("work", i)
}
func main() {
for i := 0; i < 3; i++ {
oneIteration(i)
}
}
// work 0
// cleanup 0
// work 1
// cleanup 1
// work 2
// cleanup 2
7. Example: exporting a report with correct defer and Close
Imagine our learning console app already knows how to keep a list of tasks in memory (for example, []string) and print them. Let’s add “export report to a file” as a learning illustration of resource ownership and correct closing. We are not going deep into the file system—we only care about the pattern: open → defer → handle the Close() error.
The export function (notice the named err and errors.Join):
package main
import (
"errors"
"fmt"
"os"
)
func exportTasks(path string, tasks []string) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create report %q: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil {
err = errors.Join(err, fmt.Errorf("close report %q: %w", path, cerr))
}
}()
for i, t := range tasks {
_, werr := fmt.Fprintf(f, "%d) %s\n", i+1, t)
if werr != nil {
return fmt.Errorf("write report %q: %w", path, werr)
}
}
return nil
}
And here is a minimal main that calls the export:
package main
import "fmt"
func main() {
tasks := []string{"buy milk", "read about defer", "don't panic"}
err := exportTasks("tasks.txt", tasks)
fmt.Println("export err:", err) // export err: <nil> (if everything is fine)
}
Why this example is good specifically for today’s topic:
It shows that “releasing a resource” is part of the function contract. We do not hope that the OS will “somehow close the file” on its own. We close it reliably and we do not lose the close error if it happens.
8. Common mistakes
Mistake #1: placing defer before checking err.
A very common bug looks like this: you open a resource, do not check the error, schedule defer, and then it turns out the resource does not actually exist. In the best case this leads to a panic / “nil pointer” error; in the worst case, to strange behavior. The correct order is almost always the same: first if err != nil { return ... }, and only then defer Close/Unlock.
Mistake #2: thinking that defer in a loop fires “at the end of the iteration”.
defer is tied to exiting the function, not to the loop block. So inside large loops it’s easy to accidentally “accumulate” hundreds of deferred closes and keep resources open far longer than you expected. If you need “a resource per iteration”, move the iteration into a separate function.
Mistake #3: losing the Close() error “out of habit”.
When you write data, a Close() error can be a real signal that the write did not complete correctly. Ignoring it is like telling the user “the file is saved” without looking. If the operation is important, use a named err and carefully append the close error via errors.Join, or at least return cerr when there is no main error.
Mistake #4: doing “half the business logic” inside defer and trying to keep working after a critical failure.
defer is not a place for complex scenarios, and definitely not a place where you “fix everything on the fly”. Cleanup should be predictable and short: close, unlock, release. If cleanup fails, you usually report it outward via err, not try to “continue as if nothing happened”.
Mistake #5: forgetting that Unlock() is also releasing a resource.
Sometimes people treat Unlock() like an “optional brace”, especially if they are not using goroutines yet. But a lock is an access resource. If you didn’t release it, someone else will wait later. The pattern Lock(); defer Unlock() is one of the most readable and safest ways to write a protected section of code.
GO TO FULL VERSION