Plugin System Advanced¶
Introduction¶
Go's standard plugin package provides dynamic loading of shared libraries at runtime — similar to dlopen in C. You compile Go code into a .so file with -buildmode=plugin, then load it at runtime with plugin.Open and look up exported symbols.
In practice, most Go projects don't use the plugin package. It's Linux-only (and macOS with caveats), requires exact Go version and dependency alignment between host and plugin, doesn't support unloading, and complicates deployment. Production plugin systems in Go almost always use one of the alternatives: RPC-based plugins (hashicorp/go-plugin), gRPC-based plugins, or build-time interface registration.
Understanding both the standard package and its alternatives is essential for designing extensible Go systems.
Syntax & Usage¶
Building a Plugin¶
A plugin is a Go main package compiled with -buildmode=plugin:
// plugins/greeter/greeter.go
package main
import "fmt"
type Greeter struct{}
func (g Greeter) Greet(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
// Exported variable — must be package-level
var GreeterPlugin Greeter
func Hello(name string) string {
return fmt.Sprintf("Hi there, %s!", name)
}
Loading and Using a Plugin¶
package main
import (
"fmt"
"plugin"
)
type GreeterInterface interface {
Greet(name string) string
}
func main() {
p, err := plugin.Open("plugins/greeter.so")
if err != nil {
panic(err)
}
// Look up an exported function
helloSym, err := p.Lookup("Hello")
if err != nil {
panic(err)
}
helloFunc := helloSym.(func(string) string)
fmt.Println(helloFunc("World"))
// Look up an exported variable and use via interface
greeterSym, err := p.Lookup("GreeterPlugin")
if err != nil {
panic(err)
}
greeter := greeterSym.(GreeterInterface)
fmt.Println(greeter.Greet("Alice"))
}
plugin Package API¶
| Function / Method | Purpose |
|---|---|
plugin.Open(path) |
Load a .so plugin file, returns *Plugin |
(*Plugin).Lookup(name) |
Find an exported symbol by name, returns plugin.Symbol (any) |
Type assertion on Symbol |
Cast to the expected function or interface type |
Limitations of the plugin Package¶
| Limitation | Impact |
|---|---|
| Linux-only (macOS with caveats) | Not portable — no Windows, no Alpine/musl |
| Exact Go version match | Plugin and host must use identical Go compiler version |
| Identical dependency versions | Shared dependencies must be at exactly the same version |
| No unloading | Once loaded, plugins can never be freed from memory |
| No Windows support | Eliminates a major deployment target |
-buildmode=plugin only |
Cannot use static binaries (CGO_ENABLED=0) |
| Debugging difficulty | Stack traces across plugin boundaries are harder to follow |
| No type checking at load | Lookup returns any — runtime panics if types don't match |
Production Alternatives¶
Alternative 1: hashicorp/go-plugin (RPC/gRPC)¶
The most widely used plugin system in the Go ecosystem. Used by Terraform, Vault, Packer, and Nomad. Each plugin runs as a separate process communicating over RPC or gRPC.
// Shared interface definition (in a shared package)
package shared
type KVStore interface {
Get(key string) (string, error)
Put(key, value string) error
}
// Plugin implementation (separate binary)
package main
import (
"github.com/hashicorp/go-plugin"
"myapp/shared"
)
type RedisKV struct{}
func (r *RedisKV) Get(key string) (string, error) {
return "value-from-redis", nil
}
func (r *RedisKV) Put(key, value string) error {
return nil
}
func main() {
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: shared.Handshake,
Plugins: map[string]plugin.Plugin{
"kv": &shared.KVPlugin{Impl: &RedisKV{}},
},
GRPCServer: plugin.DefaultGRPCServer,
})
}
// Host application
package main
import (
"fmt"
"os/exec"
"github.com/hashicorp/go-plugin"
"myapp/shared"
)
func main() {
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: shared.Handshake,
Plugins: shared.PluginMap,
Cmd: exec.Command("./plugins/redis-kv"),
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
})
defer client.Kill()
rpcClient, _ := client.Client()
raw, _ := rpcClient.Dispense("kv")
kv := raw.(shared.KVStore)
kv.Put("hello", "world")
val, _ := kv.Get("hello")
fmt.Println(val) // "value-from-redis"
}
Advantages: Cross-platform, crash isolation (plugin crash doesn't kill host), plugins can be written in any language (via gRPC), independent deployment, no version coupling.
Alternative 2: gRPC-Based Plugins¶
For polyglot environments, define plugins as gRPC services:
// plugin.proto
syntax = "proto3";
service PluginService {
rpc Execute(Request) returns (Response);
rpc HealthCheck(Empty) returns (HealthResponse);
}
message Request {
string action = 1;
bytes payload = 2;
}
message Response {
bytes result = 1;
string error = 2;
}
// Host discovers and communicates with plugins over gRPC
func loadPlugins(ctx context.Context, pluginAddrs []string) []PluginServiceClient {
var clients []PluginServiceClient
for _, addr := range pluginAddrs {
conn, err := grpc.DialContext(ctx, addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Printf("failed to connect to plugin %s: %v", addr, err)
continue
}
clients = append(clients, NewPluginServiceClient(conn))
}
return clients
}
Advantages: Language-agnostic, well-defined API contracts via protobuf, standard tooling for health checks and load balancing, works over network boundaries.
Alternative 3: Interface-Based Plugin System (Build-Time)¶
The simplest approach — plugins register themselves at compile time via init() and a shared registry:
// plugin/registry.go
package plugin
type Processor interface {
Name() string
Process(data []byte) ([]byte, error)
}
var registry = make(map[string]Processor)
func Register(p Processor) {
registry[p.Name()] = p
}
func Get(name string) (Processor, bool) {
p, ok := registry[name]
return p, ok
}
func All() map[string]Processor {
return registry
}
// plugins/compress/compress.go
package compress
import "myapp/plugin"
type GzipProcessor struct{}
func (g GzipProcessor) Name() string { return "gzip" }
func (g GzipProcessor) Process(data []byte) ([]byte, error) {
return gzipCompress(data)
}
func init() {
plugin.Register(GzipProcessor{})
}
// main.go
package main
import (
"myapp/plugin"
_ "myapp/plugins/compress" // blank import triggers init()
_ "myapp/plugins/encrypt" // add more plugins here
)
func main() {
p, ok := plugin.Get("gzip")
if !ok {
log.Fatal("plugin not found")
}
result, err := p.Process(data)
// ...
}
Advantages: No runtime loading, full type safety, works on all platforms, no version coupling, simple debugging, standard Go tooling. Disadvantage: Plugins must be compiled into the binary — can't add them at runtime.
Alternative 4: Script-Based Plugins (Embedded Interpreters)¶
Embed a scripting language for runtime-extensible plugins:
// Using github.com/traefik/yaegi (Go interpreter)
import "github.com/traefik/yaegi/interp"
func runGoPlugin(src string) error {
i := interp.New(interp.Options{})
i.Use(stdlib.Symbols)
_, err := i.Eval(src)
return err
}
Other options include embedding Lua (via gopher-lua), JavaScript (via goja), or Wasm (via wazero).
Comparison of Plugin Approaches¶
| Approach | Runtime Loading | Cross-Platform | Type Safety | Crash Isolation | Complexity |
|---|---|---|---|---|---|
plugin package |
Yes | No (Linux) | No | No | Low |
| hashicorp/go-plugin | Yes | Yes | Via interface | Yes | Medium |
| gRPC plugins | Yes | Yes | Via protobuf | Yes | Medium–High |
| Interface registry (build-time) | No | Yes | Yes | No | Low |
| Embedded interpreter | Yes | Yes | No | Partial | Medium |
Quick Reference¶
| Concept | Detail |
|---|---|
| Build a plugin | go build -buildmode=plugin -o x.so ./x/ |
| Load a plugin | p, err := plugin.Open("x.so") |
| Lookup symbol | sym, err := p.Lookup("ExportedName") |
| Type assert | fn := sym.(func(string) string) |
| Platform support | Linux only (macOS experimental) |
| hashicorp/go-plugin | Process-based, RPC/gRPC communication |
| Interface registry | Compile-time, init() + blank imports |
| gRPC plugins | Language-agnostic, protobuf contracts |
Best Practices¶
- Default to interface-based registration — for most Go projects, compile-time plugin registration via interfaces and
init()is the simplest, safest, and most portable approach. - Use hashicorp/go-plugin for process isolation — when plugins come from untrusted sources or when crash isolation is required (e.g., Terraform providers).
- Avoid the
pluginpackage in new projects — the platform restrictions, version coupling, and operational complexity rarely justify its use. - Define plugin contracts as interfaces — regardless of the mechanism, define the plugin API as a Go interface. This gives you type safety and makes swapping implementations trivial.
- Version your plugin API — include a version in the handshake or interface to handle backward/forward compatibility.
- Health checks for process-based plugins — if plugins are separate processes, implement health checks and restart logic. hashicorp/go-plugin does this automatically.
Common Pitfalls¶
Go version mismatch with plugin package
# Plugin compiled with Go 1.21
go1.21 build -buildmode=plugin -o myplugin.so
# Host compiled with Go 1.22
go1.22 build -o myapp .
# plugin.Open fails: "plugin was built with a different version of package runtime"
plugin package requires identical Go compiler versions for host and plugin. This makes independent deployment of plugins extremely fragile.
Dependency version mismatch
If the host uses google.golang.org/grpc v1.60.0 and the plugin uses v1.58.0, plugin.Open will fail. Every shared dependency must be at the exact same version — making dependency management a nightmare.
No plugin unloading
Loaded plugins cannot be unloaded. If you need to reload plugins, use process-based approaches (hashicorp/go-plugin) where you can kill and restart the plugin process.Symbol lookup is stringly-typed
sym, _ := p.Lookup("Proccess") // typo: "Proccess" instead of "Process"
// sym is nil, err says symbol not found — no compile-time checking
Lookup uses string names — typos are caught at runtime, not compile time. Interface-based registration avoids this entirely.
Performance Considerations¶
plugin.Openis slow: Loading a.sofile involves dynamic linking — expect 10–100ms depending on plugin size. Do it once at startup, not per-request.- Function calls are fast: Once loaded, calling a function through a type-asserted symbol is as fast as any Go function call — no overhead beyond the initial lookup.
- hashicorp/go-plugin overhead: Each call crosses a process boundary via RPC/gRPC. Expect ~50–500µs per call depending on payload size. Not suitable for tight loops with thousands of calls per second, but perfectly fine for coarse-grained operations.
- Interface registry has zero overhead: Build-time registration compiles plugins directly into the binary — function calls are direct, no indirection beyond the interface dispatch.
- Memory: The
pluginpackage loads the entire.sointo memory and never releases it. Process-based plugins use separate process memory, which can be reclaimed when the plugin exits.
Interview Tips¶
Interview Tip
"How would you design a plugin system in Go?" Start with the requirements: Does it need runtime loading? Cross-platform? Crash isolation? For most cases, define a Go interface for the plugin contract and use build-time registration with init() and blank imports. If runtime loading is needed, use hashicorp/go-plugin (separate process, gRPC communication). Avoid the standard plugin package — it's Linux-only and has severe version coupling.
Interview Tip
"Why don't most Go projects use the plugin package?" Four dealbreakers: (1) Linux-only. (2) Requires identical Go compiler version for host and plugin. (3) Requires identical versions of all shared dependencies. (4) No unloading — memory grows forever. hashicorp/go-plugin solves all of these by running plugins as separate processes communicating over gRPC.
Interview Tip
"How does hashicorp/go-plugin work?" The host spawns the plugin as a child process. They negotiate a handshake (version, protocol), then communicate over gRPC (or net/rpc). The host gets a client stub that implements the plugin interface. If the plugin crashes, the host detects it and can restart. This gives crash isolation, cross-platform support, and even cross-language plugins (anything that speaks gRPC).
Interview Tip
"What's the simplest extensible architecture in Go?" Define an interface, create a package-level registry map, have plugins call Register() in their init() function, and use blank imports (_ "myapp/plugins/foo") in main to pull them in. It's type-safe, zero overhead, works everywhere, and is the pattern used by database/sql drivers, image format decoders, and many Go libraries.
Key Takeaways¶
- Go's
pluginpackage provides dynamic loading viaplugin.OpenandLookup, but is Linux-only and requires exact version alignment. - Most production Go systems avoid the
pluginpackage — the constraints are too severe for real-world deployment. - hashicorp/go-plugin is the gold standard for runtime plugin loading — process-isolated, cross-platform, gRPC-based, used by Terraform/Vault.
- Interface-based registration (compile-time) is the simplest and most common pattern — zero overhead, fully type-safe, works everywhere.
- The
database/sqldriver model (interface +init()registration + blank imports) is Go's canonical plugin pattern. - For polyglot environments, gRPC-based plugins give language-agnostic contracts with strong tooling.
- Always define plugin contracts as Go interfaces — this decouples the mechanism from the API.