JavaRush /Courses /Go SELF /init() and side effects

init() and side effects

Go SELF
Level 10, Lesson 4
Available

1. init() in Go: what it is and why to be careful with it

When you write your first programs, you want everything to start “by itself”: you import a package—and it somehow prepares itself, configures itself, and starts working. In Go, there is a special function for this: init(). It is called automatically, without your direct involvement, and it runs before main(). That’s convenient, but also dangerous: init() easily turns a program into a collection of surprises.

The signature of init() is very strict: no parameters and no return values. In other words, it looks like this:

package main

func init() {
	// automatic initialization
}

Precisely because it’s “automatic,” init() is treated with caution: it does work implicitly. That is the key word of this lecture: implicitness.

Mechanically, init() is part of the package initialization process: the Go runtime performs package initialization (including setting global variables and calling init()) and only then moves on to main().

Execution order of init(): a simple timeline

It’s very tempting to think that Go reads files top to bottom and does exactly what you wrote. Within a single file, it often feels like that, but at the level of packages and multiple files, the picture is a bit more complex. That’s why it’s useful to keep a simple timeline in mind: what runs earlier, what runs later, and which expectations are safe.

Simplified, the order is:

  1. first, imported packages are initialized (their variables and their init()),
  2. then the current package is initialized,
  3. then main() from package main is started.

Here’s an illustration (the diagram is intentionally rough so you can remember it without notes):

flowchart TD
    A[Imported packages] --> B[Initialization of package-level variables]
    B --> C["init() calls in the current package"]
    C --> D["main()"]

The most reliable fact you need at the start: init() always runs before main() (if the package participates in the build at all).

Mini demo:

package main

import "fmt"

func init() {
	fmt.Println("init") // init
}

func main() {
	fmt.Println("main") // main
}

That’s predictable and useful for understanding. But then the nuances begin.

If you have multiple init() functions (and Go allows this), they will all run before main(). But exactly on which line and in what order across files is not something you should rely on when designing logic. If you build a program so that “init() in file A must run before init() in file B,” you are almost guaranteed to grow a future bug—you just don’t see it yet because it’s still small and cute.

Side effects and why init() amplifies them

The phrase “side effect” sounds like a warning on medicine: “may cause drowsiness, a sudden desire to rewrite the project in Rust, and intolerance to global variables.” But the idea is simpler: a side effect is when a function does something in addition to computing a result. For example, it writes to a file, changes a global variable, prints to the screen, registers a handler somewhere “inside the package.”

init() is almost always about side effects, because it returns nothing. It cannot say “I initialized successfully” or “I couldn’t read the config, here’s an error.” That’s why the typical style “do something important, and if it fails—return an error” doesn’t fit into init().

That leads to two typical paths, both unpleasant.

The first path is “silently swallow the problem.” For example, you tried to prepare a value, it didn’t work, and you left everything as-is. Then in main() it suddenly becomes "" or 0, and you’re sitting there wondering who stole your data.

The second path is “panic.” That is, in init() people call panic(...), so the program simply crashes on startup. Sometimes that’s acceptable, but often it turns the user experience (and your debugging experience) into a drama with no intermission.

Let’s compare two approaches with a very simple example. Suppose we want to compute a “prefix” for output just once.

Version with init() (hidden initialization):

package main

import "fmt"

var prefix string

func init() {
	prefix = ">> "
}

func main() {
	fmt.Println(prefix + "hello") // >> hello
}

Now a version with explicit initialization (you can immediately see where it happens):

package main

import "fmt"

func main() {
	prefix := ">> "
	fmt.Println(prefix + "hello") // >> hello
}

In such a toy example, the difference looks funny. But in a real project, “prefix” quickly turns into “config,” “connection,” “registration,” “cache,” “encoding table”… and that’s where init() starts making code “magical.”

2. When init() is acceptable and what to use instead

When init() is truly acceptable

Banning init() entirely would be odd: it exists in the language for a reason. The question is not “use it or not,” but “what exactly to put there so you don’t regret it later.”

A reasonable area for using init() at your current level is a small setup that:

  1. cannot “fail” with a normal error (or failing really is correct),
  2. does not depend on user input, files, or the network,
  3. does not hide the application startup business logic.

The clearest example is seeding a pseudo-random number generator for some internal needs (not for cryptography—just to make a “random ID” or “shuffle a list”).

Let’s build this into a learning console app. Suppose we have a mini program that reads numbers and prints simple statistics (sum and max). We’ll add a “random run ID” to distinguish runs (purely for demonstration).

package main

import (
	"fmt"
	"math/rand"
	"time"
)

var runID int

func init() {
	runID = rand.New(rand.NewSource(time.Now().UnixNano())).Intn(1000)
}

func main() {
	fmt.Println("run id:", runID) // run id: 123 (example)
}

This is acceptable as a mechanics demo, but notice: we already pulled in time, we already made a global variable, and we already hid the meaning of “where runID comes from.” It’s not a crime, but it’s “debt” you’ll have to remember.

If you feel that init() is starting to grow (conditions, error handling, reading something from somewhere), that’s almost always a sign: it’s time to stop and switch to explicit initialization.

Prefer explicit initialization: initApp() is called from main

When a program is small, init() feels convenient: “I’ll configure it once—and that’s it.” But as soon as you have multiple startup steps, you want one obvious route: what happens first, what happens next, where to handle an error, and where to print a clear message.

That’s why a good learning pattern (and it’s also very common in real projects) is to create an explicit initialization function, for example initApp(), and call it in main().

Let’s imagine our “mini statistics calculator” should have an output prefix and a start time. Let’s do it explicitly:

package main

import (
	"fmt"
	"time"
)

func initApp() (string, time.Time) {
	prefix := "stats: "
	startedAt := time.Now()
	return prefix, startedAt
}

func main() {
	prefix, startedAt := initApp()
	fmt.Println(prefix, "started at", startedAt.Format(time.Kitchen))
}

Yes, the code got a couple lines longer. But now:

  • you can see exactly what is being initialized,
  • you can see where it happens,
  • you can easily add error handling (via (T, error)) without turning everything into panic.

If initApp() could fail, we would do it like this:

package main

import (
	"errors"
	"fmt"
)

func initApp() (string, error) {
	prefix := "stats: "
	if prefix == "" {
		return "", errors.New("empty prefix")
	}
	return prefix, nil
}

func main() {
	prefix, err := initApp()
	if err != nil {
		fmt.Println("init error:", err)
		return
	}
	fmt.Println(prefix + "ready") // stats: ready
}

Notice: this is very “our style” within the material you’ve already covered—an ordinary function, an ordinary if err != nil { ... }, no magic. And that is the reason explicit initialization almost always beats init() in application code.

3. Analogy with classes

If you came to Go from Java/Kotlin/C#/C++, your brain almost automatically tries to map things to familiar constructs. And that’s normal: it helps you learn faster. The most useful (and fairly honest) analogy at this stage is: a package in Go is often read like “a class with a bunch of statics”.

Why it looks similar at all.

  1. The “name.plusName” syntax looks like Class.method.
    We write fmt.Println, json.Marshal, http.ListenAndServe. In class-based languages this strongly resembles Console.WriteLine(...) or Math.Max(...)—a “static function call.”
  2. Inside a package, “shared things” can live at the package level.
    A package has package-level functions and variables. This is similar to static fields/methods: “one shared entity for the whole module.”
  3. init() resembles a static constructor.
    In Java you can see static { ... }, in C#—a static constructor, in Kotlin—init { ... } on an object/class. The meaning is similar: “as soon as the module/class is brought in—something runs automatically.”

Here’s the important boundary: this is an analogy for reading code, not a literal equivalence.

The key difference: a package is not a class

A package has no instances, no this, no “package object.” A package is, first and foremost:

  • a namespace (a group of related names),
  • a visibility boundary (what’s visible inside the package and what’s exported outside),
  • an initialization boundary (this is exactly where init() operates).

And “real OOP behavior” in Go appears not through packages, but through types (struct) and their methods. So the correct mental model is:

  • pkg.Name means “here is a public thing from the pkg namespace”;
  • methods “like in classes” are not pkg.method, but value.Method() (and we’ll get to this in the methods topic).

So a package helps organize code, but “object-ness” in Go lives in types, not in packages.

Why this analogy is especially useful in the init() topic

Because init() is exactly the moment when a beginner coming from OOP languages expects familiar behavior: “if I imported it, then the package set itself up—and that’s normal.” And yes, Go really allows that. But then the key Go-style difference shows up: Go doesn’t like hidden startup magic, because it hurts predictability.

Imagine two worlds:

  • The world “like in an OOP textbook”: you see Class.doSomething(), and somewhere “inside” the class prepared itself—and this is often accepted as normal.
  • The Go world: when you see import "pkg", you want to be sure that import means “I use names from this package,” not “I run hidden logic.”

init() breaks that expectation: the mere fact of importing can change the program’s behavior. And that starts to be a problem when the project grows a bit.

init() as a static constructor: similarity and danger

If you keep the analogy in mind, then init() is something like “static init,” but with a side-effect amplifier:

  • it runs automatically,
  • it runs before main,
  • it runs at package initialization time (and a package can be imported “just because a dependency was pulled in”).

And that’s why Go developers treat it cautiously: if you do important work in init(), you create a hidden startup path that is not visible from main.

4. Common mistakes when working with init() and side effects

Mistake #1: putting the “main startup logic” into init().
Sometimes beginners (and tired experienced developers too, honestly) start dragging everything into init(): printing a greeting, setting variables, reading data, even mini-algorithms. As a result, main() becomes an empty “adapter,” and the only way to understand what happens at startup is to dig through the entire package. It’s much more reliable to keep the important startup step sequence in main() and call explicit initialization functions.

Mistake #2: trying to handle errors in init() like in a normal function.
init() does not return an error, so people either ignore the error (and get weird zeros and empty strings) or trigger panic for no good reason. If initialization can fail, it’s better to make initApp() return (T, error) and handle err with the usual if err != nil { ... }, so the program exits clearly and predictably.

Mistake #3: depending on the init() order across different files in the same package.
Even if “it works on my machine,” that doesn’t mean it’s a good contract. The initialization order across files is not something you should build your program’s meaning on. If you need an order, make it explicit: call initA(), then initB() from one place, and then the order will be exactly what you wrote.

Mistake #4: using a blank import without understanding what effect you actually want.
import _ "..." looks small, but it can enable a big and hidden mechanism. If you can’t explain in two sentences what exactly that import gives you, then you probably shouldn’t be writing it yet. For learning tasks and simple apps, ordinary imports and explicit initialization are almost always enough.

Mistake #5: keeping mutable state at the package level because “it’s convenient for init.”
Often init() drags global variables along with it: “in init() I fill var cfg.” Then cfg is modified from different functions, and the program starts to look like a detective story: “who changed cfg.Timeout and why is it now 0?” It’s better to keep state local and pass it through function parameters and return values—then you can see the data flow yourself instead of searching for it with a flashlight.

1
Task
Go SELF, level 10, lesson 4
Locked
Execution order
Execution order
1
Task
Go SELF, level 10, lesson 4
Locked
Prefix in init
Prefix in init
1
Task
Go SELF, level 10, lesson 4
Locked
Explicit Initialization
Explicit Initialization
1
Task
Go SELF, level 10, lesson 4
Locked
Brackets from files
Brackets from files
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION