The mod loader problem
You are building a text editor. The core handles file I/O, syntax highlighting, and the UI loop. A user wants to add support for a new file format. Another user wants a linter that runs on save. You do not want to recompile the entire editor every time someone writes a new feature. You want the editor to scan a plugins directory, load .so or .dll files, and call functions inside them.
That is the plugin system. Go treats plugins as shared libraries that the runtime loads and links dynamically. The plugin package wraps the operating system's dynamic linker so you can open a file, find a symbol, and call Go code that was compiled separately.
Plugins are not magic. They are shared libraries with strict rules about how they are built and what they can share with the host program. Get the rules wrong and the loader fails. Get them right and you have a extensible architecture without recompiling the core.
What a plugin actually is
A Go plugin is a shared object file. On Linux it ends in .so. On macOS it is .dylib. On Windows it is .dll. The Go compiler produces this file when you pass -buildmode=plugin. The file contains compiled Go code, but it also contains metadata that the Go runtime uses to verify compatibility.
The host program loads the plugin at runtime using plugin.Open. This function calls the OS linker to map the shared library into memory. Once loaded, you use Lookup to find an exported symbol by name. The symbol returns as an any value. You perform a type assertion to convert it to the concrete type you expect, then call it.
The host and the plugin must agree on types. If the host expects a function that returns string and the plugin exports a function that returns int, the type assertion fails at runtime. The compiler cannot check this across the plugin boundary because the host does not see the plugin's source code at compile time.
Plugins are shared libraries with a Go wrapper. Treat them like dynamic linking, not magic.
Minimal working example
Here is the smallest working pair: a plugin that exports a function, and a host that loads it.
The plugin code must be in package main. The linker requires this structure for plugins. The symbol you want to export must be capitalized so it is visible outside the package.
// plugin.go
package main
// Greet returns a message from the plugin.
func Greet() string {
// This function is exported because it starts with a capital letter.
return "Hello from the plugin"
}
Build the plugin with the special build mode. This creates a shared library file.
# Compile the plugin as a shared object.
# The output name determines the extension based on the OS.
go build -buildmode=plugin -o myplugin.so plugin.go
The host program loads the file and looks up the symbol. The lookup returns any, so you must assert the type.
// host.go
package main
import (
"fmt"
"log"
"plugin"
)
func main() {
// Open loads the shared library into memory.
p, err := plugin.Open("myplugin.so")
if err != nil {
log.Fatal(err)
}
// Lookup finds the symbol by name.
// It returns any because the host does not know the type at compile time.
sym, err := p.Lookup("Greet")
if err != nil {
log.Fatal(err)
}
// Type assert converts any to the expected function signature.
// If the types do not match, this panics.
greet := sym.(func() string)
fmt.Println(greet())
}
Run the host. It prints the message from the plugin.
# output:
Hello from the plugin
The type assertion is the bridge between the host and the plugin. If the plugin changes the signature, the host crashes.
How the runtime loads code
When you call plugin.Open, the runtime checks the plugin file for a Go version stamp. The plugin must be built with the same major Go version as the host. A plugin built with Go 1.21 will not load into a host built with Go 1.20. The runtime rejects the file with an error about version mismatch.
The runtime also checks dependencies. If the plugin imports a package, the host must import the same package with the exact same module path and version. Go uses module paths to identify packages globally. If the host uses github.com/pkg/errors v1.0.0 and the plugin uses github.com/pkg/errors v1.0.1, the loader fails. The runtime sees two different packages with the same path but different content. It cannot merge them.
This dependency rule is the hardest part of plugins. You cannot just drop a plugin into a folder and expect it to work. The plugin and the host must share a consistent module graph for all common dependencies. In practice, this means the plugin should be part of the same monorepo as the host, or you must lock dependency versions across both projects.
The Lookup function searches the plugin's symbol table. It only finds exported symbols. If you try to look up a lowercase function, the compiler rejects the program with plugin: symbol Lookup: symbol not found. The symbol table is built from the package's exported names.
Plugins must be package main. The linker generates the plugin metadata based on this assumption. If you build a plugin from package mylib, the build fails. The convention is strict.
Version mismatches kill plugins. Lock your toolchain and share your dependency graph.
Realistic pattern: the interface contract
Real plugins rarely export bare functions. They export structs that implement an interface. The host defines the interface. The plugin implements it. This gives you a stable contract. The host calls methods on the interface. The plugin provides the logic.
Define the interface in the host. This is the contract the plugin must satisfy.
// host.go
package main
import (
"fmt"
"log"
"plugin"
)
// Command defines what a plugin must provide.
// The host only knows about this interface.
type Command interface {
// Name returns the command identifier.
Name() string
// Run executes the command logic.
Run() error
}
func main() {
p, err := plugin.Open("myplugin.so")
if err != nil {
log.Fatal(err)
}
// Lookup finds the exported symbol.
// The plugin must export a variable named "Command".
sym, err := p.Lookup("Command")
if err != nil {
log.Fatal(err)
}
// Type assert to the interface.
// Use the ok idiom to avoid panics on bad plugins.
cmd, ok := sym.(Command)
if !ok {
log.Fatal("plugin does not implement Command interface")
}
fmt.Printf("Loaded command: %s\n", cmd.Name())
if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
The plugin implements the interface and exports a variable. The receiver name follows Go convention: one or two letters matching the type.
// plugin.go
package main
// Echo implements the Command interface.
type Echo struct {
// Message is the text to echo.
Message string
}
// Name returns the identifier.
func (e *Echo) Name() string {
return "echo"
}
// Run prints the message.
func (e *Echo) Run() error {
// Print the message to stdout.
fmt.Println(e.Message)
return nil
}
// Command is the exported symbol the host looks up.
// It must be capitalized to be visible to Lookup.
var Command = &Echo{Message: "Hello from plugin"}
This pattern separates the contract from the implementation. The host does not need to know about Echo. It only knows about Command. You can swap plugins without changing the host code.
The if err != nil boilerplate is verbose by design. The community accepts it because it makes the unhappy path visible. Every load step can fail. Check every error.
Accept interfaces, return structs. The host accepts the interface. The plugin returns a struct that implements it.
Pitfalls and compiler traps
Plugins introduce runtime failures that the compiler cannot catch. The type assertion is the most common failure point. If the plugin exports a function with the wrong signature, the host panics with interface conversion: interface is type, not type. Always use the ok idiom for type assertions when loading plugins.
// Safe assertion prevents panic.
cmd, ok := sym.(Command)
if !ok {
// Handle the error gracefully.
log.Printf("bad plugin type: %T", sym)
}
CGO must be enabled to build plugins. The plugin build mode relies on CGO to generate the shared library. If you set CGO_ENABLED=0, the build fails with buildmode=plugin requires cgo. Ensure your build environment has a C compiler installed.
The plugin file path matters. plugin.Open takes a file path. If the file does not exist, you get an error about opening the shared object. If the path is relative, it is resolved relative to the working directory of the host process, not the host binary. Use absolute paths or resolve paths relative to the executable location.
// Resolve path relative to the executable.
// This avoids issues with working directory changes.
execPath, _ := os.Executable()
dir := filepath.Dir(execPath)
pluginPath := filepath.Join(dir, "plugins", "myplugin.so")
p, err := plugin.Open(pluginPath)
Windows requires the .dll extension. macOS requires .dylib. Linux uses .so. The Go runtime handles the extension internally when loading, but plugin.Open expects the full path including the extension. Do not strip the extension.
Security is a concern. Loading a plugin executes arbitrary code from the file. Do not load plugins from untrusted sources. A malicious plugin can read files, make network requests, or crash the host. Validate the plugin source before loading. Consider signing plugins and verifying signatures in production systems.
The worst plugin bug is the one that never logs. Always log the plugin path and the result of Lookup. If a plugin fails to load, you need to know which file caused the failure.
When to use plugins
Use the plugin package when you need to load code dynamically at runtime without recompiling the host. Use a standard library import when the code is known at compile time and you want full type safety and performance. Use an HTTP service or RPC when the plugin needs to run in a separate process for isolation or security. Use configuration files when you only need to change behavior, not logic.
Plugins add complexity. They require matching Go versions, matching dependencies, and careful error handling. Static imports are safer and easier to test. Choose plugins only when dynamic extensibility is a core requirement.
Static imports are safer. Plugins are for extensibility. Pick the boundary that matches your threat model.