When static binaries aren't enough
You wrote a command-line tool that processes images. A user wants to add a custom filter that uses a library you didn't anticipate. You don't want to maintain a fork for every custom filter. You want the user to drop a file into a plugins folder and have the tool pick it up on the next run. Go supports this, but with heavy caveats. The plugin package lets you load shared objects at runtime, but it works only on Linux and requires CGO.
How plugins break the static model
Go usually produces static binaries. Everything is baked in at compile time. The plugin package breaks this rule. It lets you compile a package into a shared object file and load it while the main program is running. Think of it like a USB stick for your code. You plug it in, the system recognizes the device, and you can read the data. In Go, you compile a specific package as a plugin, drop the .so file on disk, and the main program opens it to find exported symbols.
This is not the default Go way. It requires CGO to be enabled. It works only on Linux. macOS and Windows are not supported. The Go team considers this an experimental feature for specific use cases, not a general-purpose extension mechanism. The implementation relies on dlopen, the system call for loading shared libraries on Linux. macOS enforces code signing and has strict memory protection mechanisms that conflict with how Go loads plugins. Windows uses a different dynamic linking model. The complexity of supporting these platforms outweighed the benefits for the core team.
Plugins require CGO. CGO is the bridge between Go and C. The plugin system uses CGO to call the system dynamic linker. This means your build environment must have a C compiler installed. The build fails with cgo: C compiler "gcc" not found: exec: "gcc": executable file not found in $PATH if the toolchain is missing. It also means your plugin binary is not a pure Go binary. It carries C runtime baggage. This matters for deployment. You cannot use the plugin package in environments that forbid CGO, like some minimal container images or restricted build systems.
Plugins break the static binary promise. Use them only when you have no other choice.
Minimal example
Here's the simplest plugin: a package that exports a function, compiled as a shared object, loaded by a host program.
The plugin code lives in a separate directory. It must declare package main, even though it has no main function. The compiler rejects the build with plugin requires package main if you use any other package name.
// myplugin/plugin.go
package main
// Greeting returns a message from the plugin.
// It is exported because the name starts with a capital letter.
func Greeting() string {
return "Hello from the plugin"
}
Build the plugin with the special build mode. This flag tells the compiler to generate a shared object instead of an executable.
# Compile the plugin into a shared object file.
go build -buildmode=plugin -o myplugin.so ./myplugin
The host program loads the file and looks up the symbol. plugin.Open returns a handle to the loaded library. Lookup searches for a symbol by name and returns an interface{}. You must assert the type yourself. If the assertion fails, the program panics.
// main.go
package main
import (
"fmt"
"log"
"plugin"
)
func main() {
// Open loads the shared object file from disk.
// It returns an error if the file is missing or invalid.
p, err := plugin.Open("myplugin.so")
if err != nil {
log.Fatal(err)
}
// Lookup finds the exported symbol by name.
// The name must match the exported identifier exactly.
sym, err := p.Lookup("Greeting")
if err != nil {
log.Fatal(err)
}
// Type assertion converts the interface{} to the concrete function type.
// If the types don't match, this line panics at runtime.
fn := sym.(func() string)
fmt.Println(fn())
}
Run the host program. It prints the message from the plugin.
# Run the host program.
go run main.go
# output:
# Hello from the plugin
The compiler enforces package main. The runtime enforces type safety via assertion.
Walkthrough
The build process is the first hurdle. You cannot use go build normally. You must pass -buildmode=plugin. This tells the compiler to generate a shared object instead of an executable. The package must have a package main declaration. The compiler rejects the build with plugin requires package main if you forget this.
At runtime, plugin.Open takes a path to the .so file. It loads the library into the process memory space. Lookup searches for a symbol name. The name must match the exported identifier exactly. Go exports symbols based on capitalization. Greeting is exported. greeting is not. If you look up a missing symbol, Lookup returns an error. The return value is interface{}. Go doesn't know the type at compile time. You must assert the type. If the assertion fails, the program panics.
The plugin and the host must be built with the same major version of Go. The plugin loader checks the version of the Go runtime embedded in the shared object. If the host and plugin were built with different major versions, the load fails. The error message is often plugin: open ...: wrong ELF class or a runtime panic about version mismatch. This happens because internal data structures change between releases. You must pin the Go version in your project and ensure plugins are built with the exact same version. This creates a distribution challenge. You cannot ship a plugin built with Go 1.21 to a user running a binary built with Go 1.20.
Dependency isolation is tricky. Go plugins do not share loaded packages by default in a safe way. Two plugins might load different copies of the same library, doubling memory usage. Or they might collide on symbols. The Go runtime tries to handle this, but it's fragile. If your plugin imports a package that the host also imports, the runtime may merge them, or it may keep them separate. The behavior depends on the build configuration and can change between Go versions. Assume plugins are isolated unless you have tested the specific scenario.
Plugins run in the same process as the host. They share the same memory space. A buggy plugin can crash the entire application. A malicious plugin can read any memory, modify global state, or spawn processes. There is no sandboxing. Treat plugins like root access. Validate the source. Verify checksums. Never load plugins from untrusted directories without strict validation.
Version mismatch is the silent killer. Pin your Go version.
Realistic example
In real code, you rarely export raw functions. You define an interface in the host and have the plugin implement it. This keeps the contract stable. The host doesn't need to know the plugin's internal structure. The plugin can evolve as long as it satisfies the interface.
The host defines the interface. This file is part of the host module, not the plugin.
// host/plugin.go
package host
// Processor defines the contract for all plugins.
// Plugins must implement this interface to be loaded.
type Processor interface {
// Process transforms the input string.
Process(input string) string
}
The plugin imports the host package to access the interface. It implements the interface and exports a variable of that type. Exporting a variable of interface type lets the host receive a concrete implementation without knowing the struct details.
// myplugin/plugin.go
package main
import "host"
// MyProcessor implements the Processor interface.
type MyProcessor struct{}
// Process transforms the input string.
func (m MyProcessor) Process(input string) string {
return "Processed: " + input
}
// Plugin is the exported symbol the host will look up.
// It holds a concrete value that satisfies the interface.
var Plugin host.Processor = MyProcessor{}
Build the plugin. The plugin module must depend on the host module to import the interface definition.
# Build the plugin with the host dependency.
go build -buildmode=plugin -o myplugin.so ./myplugin
The host loads the plugin and asserts the variable to the interface type. The type assertion works because the plugin variable is already that interface type.
// main.go
package main
import (
"fmt"
"host"
"log"
"plugin"
)
func main() {
// Open loads the plugin shared object.
p, err := plugin.Open("myplugin.so")
if err != err {
log.Fatal(err)
}
// Lookup finds the exported Plugin variable.
sym, err := p.Lookup("Plugin")
if err != nil {
log.Fatal(err)
}
// Assert to the interface type defined in the host.
// This checks that the plugin exports a compatible type.
proc := sym.(host.Processor)
result := proc.Process("data")
fmt.Println(result)
}
Run the host. It prints the result from the plugin.
# Run the host program.
go run main.go
# output:
# Processed: data
If your plugin interface performs I/O, include context.Context as the first parameter. This allows the host to cancel the plugin if it hangs. Functions that take a context should respect cancellation and deadlines. This is a standard Go convention that applies to plugins just as much as to any other code.
Interfaces bridge the gap. The host defines the contract; the plugin fulfills it.
Pitfalls
The biggest trap is the Go version. The plugin and the host must be built with the same major version of Go. Mixing Go 1.20 and Go 1.21 often leads to runtime crashes or plugin: open ...: wrong ELF class errors. The compiler won't catch this. You have to manage versions carefully.
CGO is another wall. If your environment has no C compiler, plugins fail. The build fails with cgo: C compiler "gcc" not found: exec: "gcc": executable file not found in $PATH. You cannot use plugins in pure Go environments.
Dependency hell is real. Plugins can't share dependencies easily. If plugin A uses log and plugin B uses log, they might get separate copies. Or if they use a third library, symbol collisions can occur. The Go runtime tries to handle this, but it's fragile.
Security is a concern. Loading a plugin executes arbitrary code. Validate the source. Check signatures if you distribute plugins. There is no sandbox. A plugin can crash the host or access sensitive memory.
Type assertion panics are common. If the plugin exports a func() int but you assert func() string, the program crashes. Always check the type or use a type switch. The compiler rejects the program with loop variable i captured by func literal if you forget to capture loop variables in closures, but that's a general Go rule, not plugin-specific. For plugins, the risk is runtime panics from mismatched types.
Testing plugins is hard. You can't easily mock plugin.Open. You have to write integration tests that build and load real plugins. This slows down the test suite. Consider using interfaces directly in unit tests and only loading plugins in integration tests.
Run gofmt on your plugin code. The host and plugin should follow the same formatting standards to keep the codebase consistent. Most editors run gofmt on save. Don't argue about indentation; let the tool decide.
Dynamic loading is a trade-off. Pick the simplest mechanism that solves the problem.
When to use plugins
Use the plugin package when you need runtime extensibility on Linux and can guarantee the same Go version across host and plugins. Use HTTP or gRPC services when you need to support multiple operating systems or want to isolate plugin crashes from the main process. Use configuration files when the extension is just data or simple rules, not executable code. Use build tags when you want to include optional features at compile time without runtime loading overhead. Use embed when you want to bundle assets or code into the binary but don't need dynamic loading.
Dynamic loading is a trade-off. Pick the simplest mechanism that solves the problem.