JavaRush /Courses /Go SELF /replace: local overrides, risks, and when it makes sense

replace: local overrides, risks, and when it makes sense

Go SELF
Level 35, Lesson 2
Available

1. Why you need replace: “I want to test a fix, but there’s no release yet”

Imagine a typical real-world development situation: you found a bug in a library you use, and you’ve even written a fix. But the fix isn’t published yet (there’s no version tag, no release, and sometimes you don’t even have permission to push to the repository). You want to build and run your application right now with a local fix, without changing imports across the codebase and without turning the project into a manual pile of ZIP archives like “dependencies_final_final2.zip”.

That’s where replace comes in: it’s a mechanism that tells Go tooling roughly this: “when you see a module with this module path, take it not from where you normally would, but from here instead.” Important: this is a dependency resolution rule, not a change to your source code.

What replace does and what it doesn’t

When you first see replace, it’s tempting to think of it as a “magic button: download me a different version.” But it’s not downloading and not installing — it’s a remapping of the source.

More precisely, replace affects where the go tools will get a module’s sources from when they build your main module. In other words, replace lives “at the project boundary,” next to your require directives, and it affects builds and tests, not the Go language syntax.

It’s also useful to remember: Go tools are strongly oriented toward predictability and determinism. That’s why they like to pin versions and checksums. And replace is like a “service entrance”: it’s allowed, but you need to use it carefully, because it easily makes the build depend on your local machine (and local machines are creative: today they’re one thing, tomorrow it’s “oops, I reinstalled the OS”).

2. replace syntax: two main forms

Now for an important moment: we’ll look at replace not as theory, but as actual text in go.mod that you will really see in a PR diff.

Replacing with a local path

This form is used when you have a folder on disk containing another module (meaning it contains its own go.mod), and you want to temporarily use it instead of a “normally downloaded” dependency.

// go.mod (your main module)
module example.com/todoapp

go 1.25

require example.com/todolib v0.1.0

replace example.com/todolib => ../todolib

Two details are important to notice right away.

The first is that replace points to the module root, not a package subfolder. That is, ../todolib must be the directory that contains ../todolib/go.mod.

The second is that the path ../todolib is resolved relative to the folder where go.mod is located, not relative to where you launch your IDE or run go test.

Replacing with another module path and version

This form comes up when you use a fork of a dependency or an alternative source, but want to keep imports in the code unchanged.

Schematically, it can look like this:

replace example.com/todolib => example.com/todolib-fork v0.1.1

The idea: in code you still write import "example.com/todolib/task", but in reality Go will take the module from a different place. Yes, it’s like “we changed the supplier on the invoice,” but the boxes in the warehouse are still labeled with the old name.

3. Practical scenario: a shared module and wiring it in via replace

So that replace doesn’t remain an abstract boogeyman, let’s act out a simple, very real story. We have a sample app todoapp. We want to extract part of the code into a small library todolib to reuse types and functions in another project (for example, a separate utility or future experiments). But we won’t publish the library “to the internet” — we’ll just keep it next to the app on disk and connect it via replace.

Directory structure

It’s important to visualize the files. Let’s assume this structure:

workspace/
  todolib/
    go.mod
    task/
      id.go
    title/
      normalize.go

  todoapp/
    go.mod
    main.go

Notice: these are two different modules, because each folder has its own go.mod. That independence is exactly what makes replace possible and meaningful.

Minimal todolib: the ID type

Let’s make a tiny task package inside the todolib module.

package task

// ID — a task identifier.
// For now it’s just an int, but the type helps avoid mixing it up with other numbers.
type ID int

We’re not doing anything “clever” here: we’re just introducing a type. In practice, this improves readability and reduces the chance of confusing “task id” with “number of tasks” (both look like int, but the meaning is different).

One more small title package: title normalization

Let’s add a utility for the title. For now, it will only trim spaces (we’re not starting a war against all whitespace in the universe).

package title

import "strings"

// Normalize makes the title look neat.
func Normalize(s string) string {
	return strings.TrimSpace(s)
}

Using todolib in todoapp as an external dependency

Now in todoapp/main.go we import packages from todolib by module path.

package main

import (
	"fmt"

	"example.com/todolib/task"
	"example.com/todolib/title"
)

func main() {
	var id task.ID = 1
	fmt.Println("id =", id)                           // id = 1
	fmt.Println(title.Normalize("   buy milk   "))    // buy milk
}

Notice something important: the imports look like imports from a real external library. That’s the convenience: when you later decide to publish todolib or to use it without replace, you won’t need to rewrite todoapp code.

Connecting the local module via replace

Now the key moment: in todoapp/go.mod we pin the dependency and override the source.

module example.com/todoapp

go 1.25

require example.com/todolib v0.0.0

replace example.com/todolib => ../todolib

Why the version v0.0.0? Because in this scenario the version isn’t that important: we’re taking the code from a local folder anyway. In production projects, teams usually try to have proper versions, but for learning and local development this entry helps you understand the mechanics.

4. replace risks: how to turn a project into “works only on my machine”

Now for a tiny bit of “build engineer pedantry,” but without this replace becomes a trap. The main risk is very simple: replace easily breaks reproducibility.

If you wrote replace ... => ../todolib, then your project now depends on another developer having a ../todolib folder in the exact same place. On CI (where the repository is usually cloned into a clean directory), that folder won’t exist. And the build will fail.

Moreover, if you accidentally committed such a replace into the repository, it’s as if you glued a note to the project: “builds only on my laptop, because I have another repo sitting next to it.” That’s not always evil (sometimes it’s done deliberately in monorepos), but it definitely requires discipline.

There’s also a more subtle risk: when you use go install some/tool@version, Go deliberately avoids ambiguity and places restrictions on what can be in go.mod. In particular, in some scenarios replace simply cannot be used, so that building an exact tool version remains unambiguous.

One more practical observation: Go tooling often behaves “like an adult” — instead of silently trying to fix something, it throws an error and explicitly tells you which command to run. It’s worth remembering, because with incorrect dependencies you’ll see hints like “no required module provides package ...; to add it: go get ...”.

5. When replace is appropriate

So you don’t walk away thinking “wait, is this forbidden magic?”, let’s state a healthy position. replace is a normal tool. It’s just not for everyday “it’s more convenient this way,” but for specific work situations.

  • replace is appropriate when you actively develop a library and an application at the same time, but the library isn’t ready to publish yet. Historically, this was one of the standard ways to “pull in local unpublished changes,” and after publishing you had to remember to remove replace, otherwise the project became hard to move/port.
  • replace can be appropriate when you use a dependency fork (for example, you need an urgent fix). Then you can temporarily replace the module with the fork and later return to the official one.
  • replace is appropriate as a “repair mode” when the outside world is temporarily unavailable (for example, you work in an isolated environment). But that’s a rarer story, and it’s better when it’s explicitly defined and documented.

Flowchart: do we need replace?

Sometimes it’s more useful to see an algorithm once than to read ten paragraphs (even though we’ll read them anyway; we’re programmers).

flowchart TD
    A[Need to use a dependency] --> B{Is the required version available via a normal require?}
    B -->|Yes| C[Use require / go get]
    B -->|No| D{Do we have local changes to the module?}
    D -->|Yes| E[Temporarily set replace to a local path]
    D -->|No| F[Find another version/fork and switch replace to another module path]
    E --> G{Time to push/release/merge?}
    G -->|Yes| H[Remove replace or do a proper library release]
    G -->|No| I[Keep replace locally, but remember CI]

The moral here is important: replace is almost always a temporary solution. If it becomes permanent, that should be a deliberate architectural decision, not “oops, I forgot to remove the line.”

A mini hygiene rule

If you use replace, try to leave a comment next to it, so that a month later you (or your teammate) won’t wonder why the dependency is coming from ../something.

In Go, it looks perfectly normal:

replace example.com/todolib => ../todolib // local todolib development

This isn’t “beauty for beauty’s sake.” It really saves hours of your life.

A note about alternatives

There is a workspaces mechanism (go.work), which is designed specifically to make it easier to work with multiple modules at once without constantly editing go.mod. Official materials explicitly contrast it with the old “manual replace for local unpublished changes.”

But within this lecture we keep the focus: today it’s important to learn to read and understand replace, because you will definitely encounter it in other people’s projects — even if you personally prefer workspaces.

6. Common mistakes when working with replace

Mistake No. 1: replace points to a package subfolder, not the module root.
A very common beginner confusion: “I need the task package, it lives in ../todolib/task, so I should replace to that.” But replace works at the module level, and a module is defined by the go.mod file. So the correct target is the directory that contains go.mod, and the packages are inside it.

Mistake No. 2: accidentally committed a local replace and broke the build for everyone else.
Classic genre: everything works for you, you happily open a PR, and your colleague (or CI) gets an error like “folder does not exist.” The reason is that ../todolib is a detail of your filesystem. If replace is only needed locally, most of the time it should not live on the main branch.

Mistake No. 3: thinking replace “downloads” or “updates” a dependency.
replace doesn’t download or update anything. It only changes the rule “where to get the sources from.” As a result, you can end up in a weird situation: you think you’re using v1.2.3 because that’s what require says, but in reality the code is coming from a local folder. This is especially unpleasant while debugging: you look at one version, but you build a different one.

Mistake No. 4: forgetting to remove replace when changes are already published or no longer needed.
Historically, this was a real pain: you made a local fix, wired it in via replace, then published the module, but forgot to remove replace — and the project keeps referencing local sources that others don’t have. In official materials about workspaces, this scenario is explicitly mentioned as a common reason people wanted a more convenient multi-module workflow.

Mistake No. 5: being surprised that some commands behave more strictly because of replace.
For example, there are scenarios where Go intentionally restricts ambiguity when installing tools by an exact version, and replace is prohibited there. If you run into an unexpected “not allowed,” it’s not Go personally disliking you — it’s an attempt to keep behavior deterministic.

1
Task
Go SELF, level 35, lesson 2
Locked
Adjacent library
Adjacent library
1
Task
Go SELF, level 35, lesson 2
Locked
Relative replace
Relative replace
1
Task
Go SELF, level 35, lesson 2
Locked
Replace with a fork
Replace with a fork
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION