Go version

go version go1.25.0 linux/amd64

Output of go env in your module/workspace:

AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/home/flo/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/flo/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build965605651=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/home/flo/tmp/go-panic-print/go.mod'
GOMODCACHE='/home/flo/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/flo/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/usr/local/go'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/flo/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/usr/local/go/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.25.0'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

Reproducer:

package main

import "github.com/florianl/foo"

func main() {
    foo.Bar()
}

module github.com/florianl/issue-75198

go 1.25.0

require github.com/florianl/foo v0.0.0-20250830101743-a8ea00ca555f

replace github.com/florianl/foo => github.com/florianl/bar v0.0.0-20250830103411-1f7d777651a2

What did you see happen?

When building the reproducer with -trimpath the following stack will be printed as triggered by the panic():

$ go clean
$ go build -trimpath ./...
$ ./issue-75198 
panic: from bar

goroutine 1 [running]:
github.com/florianl/foo.bar()
    github.com/florianl/foo@v0.0.0-20250830101743-a8ea00ca555f/bar.go:9 +0x25
github.com/florianl/foo.Bar(...)
    github.com/florianl/foo@v0.0.0-20250830101743-a8ea00ca555f/bar.go:4
main.main()
    github.com/florianl/issue-75198/main.go:6 +0x10

Please note that the file paths in the stack frames still reference github.com/florianl/foo@v0.0.0-20250830101743-a8ea00ca555f.

When building the reproducer without -trimpath the correct paths are shown in the stacktrace:

$ go clean
$ go build ./...
$ ./issue-75198 
panic: from bar

goroutine 1 [running]:
github.com/florianl/foo.bar()
    /home/user/go/pkg/mod/github.com/florianl/bar@v0.0.0-20250830103411-1f7d777651a2/bar.go:9 +0x25
github.com/florianl/foo.Bar(...)
    /home/user/go/pkg/mod/github.com/florianl/bar@v0.0.0-20250830103411-1f7d777651a2/bar.go:4
main.main()
    /home/user/issue-75198/main.go:6 +0x10

What did you expect to see?

The expected stacktrace should look like this:

$ go clean
$ go build -trimpath ./...
$ ./issue-75198 
panic: from bar

goroutine 1 [running]:
github.com/florianl/bar.bar()
    github.com/florianl/bar@v0.0.0-20250830103411-1f7d777651a2/bar.go:9 +0x25
github.com/florianl/bar.Bar(...)
    github.com/florianl/bar@v0.0.0-20250830103411-1f7d777651a2/bar.go:4

main.main()
    github.com/florianl/issue-75198/main.go:6 +0x10

Comment From: gabyhelp

Related Issues

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)

Comment From: seankhliao

replace replaces the source files of given dependency with those of the target, but it doesn't change the identity (name+version) of the dependency. -trimpath uses module identity as the source location.

replace also supports local replaces. What should -trimpath with replace example.com/foo => ../my-local-foo use as the source location if we don't use module identity?

Comment From: florianl

Looks like @gabyhelp is doing a better job in searching and identifying https://github.com/golang/go/issues/68493 as possible duplicate.

replace replaces the source files of given dependency with those of the target, but it doesn't change the identity (name+version) of the dependency.

I think, replace works just fine. In all produced stacktraces, the private function bar(), that exists only in the module that replaces the original, is listed correctly.

Looking at this example, where -trimpath is used:

github.com/florianl/foo.bar()
    github.com/florianl/foo@v0.0.0-20250830101743-a8ea00ca555f/bar.go:9 +0x25

the module github.com/florianl/foo does not have a private function named bar(), that is called by the public Bar() function.

replace also supports local replaces. What should -trimpath with replace example.com/foo => ../my-local-foo use as the source location if we don't use module identity?

For the named local replacement, I would expect not to get example.com/foo for the module identity. The module identity should hint towards my-local-foo. But I don't have an opinion on wether the relative or absolute path should be used.

Comment From: zigo101

@seankhliao did you locked https://github.com/golang/go/issues/68493?

Comment From: prattmic

cc @golang/command-line

Comment From: florianl

To help investigate this issue, I did create a test script file mod_replace_trimpath.txt that can be place in src/cmd/go/testdata/script/. :

# Test that 'go build -trimpath' works correctly with module replacements.
# This test verifies that when using both replace statements in go.mod
# and the -trimpath flag, the built binary contains properly trimmed paths.

[short] skip

# populate go.sum
go get

# Test 1: Build without -trimpath - should contain workspace paths
go build -o without-trimpath.exe .
exec ./without-trimpath.exe
stdout 'Hello from replacement dependency'
stdout 'Main contains workspace path: true'
stdout 'Dep contains workspace path: true'
stdout 'Dep contains example.com in path: false'

# Test 2: Build with -trimpath - should not contain workspace paths
go build -trimpath -o with-trimpath.exe .
exec ./with-trimpath.exe
stdout 'Hello from replacement dependency'
stdout 'Main contains workspace path: false'
stdout 'Dep contains workspace path: false'
stdout 'Dep contains example.com in path: false'

# Test 3: Verify that the replacement is actually being used
# The message should come from the replacement, not the original
! stdout 'Hello from original dependency'

# Test 4: Verify go list shows the replacement correctly
go list -m -f '{{.Path}} {{.Version}}{{with .Replace}} => {{.Path}}{{end}}' example.com/original-dep
stdout 'example.com/original-dep v1.0.0 => ./replacement-dep'

-- go.mod --
module example.com/main

go 1.25

require example.com/original-dep v1.0.0

replace example.com/original-dep v1.0.0 => ./replacement-dep

-- main.go --
package main

import (
    "fmt"
    "runtime"
    "strings"

    "example.com/original-dep"
)

func main() {
    // Print message to verify replacement is working
    fmt.Println(dep.Message())

    // Get the current file path to check trimpath behavior
    _, file, _, _ := runtime.Caller(0)
    fmt.Printf("Main file path: %s\n", file)

    // Get file path from dependency to check trimpath behavior
    depFile := dep.GetCallerFile()
    fmt.Printf("Dep file path: %s\n", depFile)

    // Check if paths contain workspace directory (should not with -trimpath)
    // Look for the temporary test directory structure
    workspaceInMain := strings.Contains(file, "/tmp/") || strings.Contains(file, "gopath")
    workspaceInDep := strings.Contains(depFile, "/tmp/") || strings.Contains(depFile, "gopath")
    exampleComInDep := strings.Contains(depFile, "example")

    fmt.Printf("Main contains workspace path: %t\n", workspaceInMain)
    fmt.Printf("Dep contains workspace path: %t\n", workspaceInDep)
    fmt.Printf("Dep contains example.com in path: %t\n", exampleComInDep)
}

-- replacement-dep/go.mod --
module example.com/replacement-dep

go 1.21

-- replacement-dep/replacement.go --
package dep

import "runtime"

// GetCallerFile returns the file path of the caller for testing trimpath behavior
func GetCallerFile() string {
    _, file, _, _ := runtime.Caller(0)
    return file
}

func Message() string {
    return "Hello from replacement dependency"
}

-- original-dep/go.mod --
module example.com/original-dep

go 1.21

-- original-dep/dep.go --
package dep

import "runtime"

// GetCallerFile returns the file path of the caller for testing trimpath behavior
func GetCallerFile() string {
    _, file, _, _ := runtime.Caller(0)
    return file
}

func Message() string {
    return "Hello from original dependency"
}