When your app needs to grow without restarting
You are building a CLI tool that analyzes log files. It works great for JSON and plain text. Then a user asks for CSV support. Another wants XML. A third wants to parse proprietary binary formats. You could add every format to your main binary, but that bloats the executable and forces everyone to download code they never use. You could write a separate program for each format, but then you lose the shared configuration and logging infrastructure. You need a way to drop a new file into a directory and have your application recognize it instantly, without rebuilding or restarting.
What a plugin actually is
Go plugins solve this by compiling a package into a shared library that the operating system loads at runtime. Think of it like a USB drive. Your computer has a standard port and a standard protocol for reading files. You do not need to know what is on the drive before you plug it in. The OS mounts the drive, reads the directory, and hands you the files. A Go plugin works the same way. Your main program defines the port. The plugin defines the data and functions. At runtime, the program opens the library, looks up the exported symbols, and calls them.
The trade-off is strict compatibility. The plugin and the host must speak the exact same version of the Go runtime. They must share identical dependency trees. If the host expects a struct with three fields and the plugin sends one with four, the memory layout breaks. Go does not hide this complexity behind a virtual machine. It hands you the raw shared object and expects you to manage the boundaries.
Plugins are not a language feature. They are a bridge to the operating system's dynamic linker. On Linux and macOS, this is dlopen. On Windows, it is LoadLibrary. The Go toolchain compiles your package into a .so, .dylib, or .dll file. The standard library plugin package wraps the OS calls and adds type-safe symbol lookup. You are responsible for keeping the two binaries in sync.
The minimal working example
Here is the simplest possible plugin. It exports a single function that returns a string.
// plugin/hello.go
package main
// Greet returns a simple greeting string.
// Capitalized names are the only symbols visible to the host.
func Greet() string {
// Return a plain string. Strings are cheap to pass by value.
return "Hello from the plugin"
}
Build it with the plugin build mode. This tells the Go toolchain to emit a shared object instead of an executable.
go build -buildmode=plugin -o hello.so plugin/hello.go
The host program opens that file, finds the symbol, and calls it.
// main.go
package main
import (
"fmt"
"plugin"
)
func main() {
// Open the shared object file. Returns an error if missing or corrupt.
p, err := plugin.Open("hello.so")
if err != nil {
panic(err)
}
// Lookup searches the symbol table by exact name.
sym, err := p.Lookup("Greet")
if err != nil {
panic(err)
}
// Type assert the interface{} to the actual function signature.
// The compiler cannot verify this at build time.
greet := sym.(func() string)
fmt.Println(greet())
}
Run go run main.go and you get the greeting. The plugin compiled separately, loaded at runtime, and executed inside the host process.
Plugins are just shared libraries with a Go wrapper. Treat them like external binaries, not internal packages.
How the runtime loads it
What happens under the hood? The plugin.Open call delegates to the operating system's dynamic linker. The linker maps the shared object into the host's virtual memory space. It resolves global variables and function addresses. The plugin.Lookup call searches the symbol table of that mapped memory. It returns an interface{} because Go cannot know the exact type at compile time. You must type assert it to the correct signature. If the assertion fails, the program panics. There is no gradual degradation. The type system catches mismatches at runtime, not compile time.
Go functions are not automatically exported. Only capitalized names appear in the symbol table. If you name your function greet with a lowercase letter, Lookup returns an error. The compiler enforces visibility rules, and the plugin system respects them. Public names start with a capital letter. Private start lowercase. There are no keywords like public or private. The capitalization is the boundary.
Memory management crosses the boundary seamlessly. If a plugin allocates a slice and returns it to the host, the host's garbage collector tracks it. If the plugin closes a channel that the host is reading from, the host unblocks. Goroutines spawned inside a plugin run in the same thread pool as the host. A leaked goroutine in a plugin leaks memory in the host process. Always design cancellation paths that cross plugin boundaries. Context propagation works normally because context.Context is just a struct with methods. Pass it as the first parameter, conventionally named ctx, and respect deadlines.
The worst plugin bug is a silent memory leak that only appears after hours of uptime. Design your contracts to return errors, not panics.
Designing a stable contract
Real applications rarely load a single function. They load a set of capabilities behind a common interface. This keeps the host decoupled from the plugin's internal implementation.
Define the contract in a shared package. Both the host and the plugin import it.
// contract/processor.go
package contract
// Processor defines the interface for log parsing plugins.
// Interfaces are accepted at boundaries. Structs are returned.
type Processor interface {
// Name returns the identifier for this processor.
Name() string
// Parse reads raw bytes and returns structured results.
Parse(data []byte) ([]string, error)
}
The plugin implements the interface and exports a factory function. The factory returns the interface type, not the concrete struct. This follows the Go convention of accepting interfaces and returning structs, but flipped for plugin boundaries. The plugin returns an interface so the host can use it without knowing the concrete type.
// csv_plugin/csv.go
package main
import "contract"
type csvProcessor struct{}
// Name returns the processor identifier.
// Receiver name is one letter matching the type.
func (p csvProcessor) Name() string {
return "csv"
}
// Parse splits input by newlines and commas.
func (p csvProcessor) Parse(data []byte) ([]string, error) {
// Simple split for demonstration. Real parsers use encoding/csv.
var result []string
for _, line := range string(data) {
result = append(result, string(line))
}
return result, nil
}
// NewProcessor creates and returns a new CSV processor.
// Factory functions keep construction logic separate from usage.
func NewProcessor() contract.Processor {
return csvProcessor{}
}
Build it the same way. The host loads the factory, calls it, and uses the returned interface.
// main.go
package main
import (
"fmt"
"plugin"
)
func main() {
// Open the shared object file.
p, err := plugin.Open("csv.so")
if err != nil {
panic(err)
}
// Lookup finds the exported factory symbol.
sym, err := p.Lookup("NewProcessor")
if err != nil {
panic(err)
}
// Type assert to the factory signature.
factory := sym.(func() contract.Processor)
proc := factory()
fmt.Println(proc.Name())
}
The host never imports the plugin package. It only knows about the contract package and the plugin standard library. This separation lets you swap implementations without touching the host code. Error handling follows the standard pattern. Check if err != nil { return err } at every boundary. The boilerplate makes the unhappy path visible.
The hidden costs and compiler traps
Plugins introduce friction that standard Go packages avoid. The first friction point is CGO. The plugin package requires CGO to be enabled. If your environment disables CGO, the compiler rejects the build with plugin: plugin.Open requires cgo. You cannot compile plugins on pure Go toolchains or in environments that strip C dependencies.
The second friction point is module paths. The host and the plugin must share the exact same module path for every imported dependency. If the host imports github.com/example/utils v1.2.0 and the plugin imports github.com/example/utils v1.3.0, the dynamic linker fails. The runtime complains with plugin: open /path/to/plugin.so: symbol lookup error: undefined symbol. The linker cannot merge two different versions of the same package into one address space. You must pin dependency versions across the entire project tree. Use a shared go.mod or a workspace to keep versions synchronized.
The third friction point is binary compatibility. Go does not guarantee ABI stability across minor versions. A plugin compiled with Go 1.21 will likely crash when loaded by a host built with Go 1.22. The runtime checks the version and aborts with plugin: plugin was built with a different version of the runtime. You must rebuild plugins whenever you upgrade the host compiler. This makes distribution harder. You cannot ship a single plugin binary to all users. You must provide builds for each Go version and operating system combination.
Testing plugins requires extra steps. You cannot import a plugin package directly in a test file. You must compile the plugin to disk, run the host test binary, and verify the loaded behavior. This slows down feedback loops. Many teams write the core logic as a regular package, test it normally, and only compile it as a plugin during the release build. The //go:build directive helps separate plugin targets from standard builds.
Formatting stays consistent across boundaries. Run gofmt on every file. Most editors run it on save. Argue logic, not indentation.
When to use plugins versus alternatives
Use a Go plugin when you need runtime extensibility and can control the build environment for both host and extensions. Use a separate microservice when you need language independence, process isolation, or independent deployment cycles. Use a configuration file or JSON schema when the extension only changes behavior, not logic. Use a standard library import when you want compile-time safety, fast startup, and zero runtime friction.