You need to talk to the cluster
You're writing a Go service that lives inside a Kubernetes cluster. It needs to know when a new Pod starts, or it needs to create a ConfigMap on the fly. You can't just curl the API server and parse JSON by hand. You need a client library that speaks Kubernetes fluently. client-go is that library. It's the official Go client for the Kubernetes API. It handles authentication, retries, and type safety so you don't have to.
How client-go works
Kubernetes exposes everything through a REST API. Every resourceβPods, Deployments, Namespacesβis just an object with a URL. client-go wraps that API in Go structs and methods. Instead of building HTTP requests, you call methods like Get or Create on typed objects. The library translates your Go calls into JSON payloads, sends them to the API server, and turns the response back into Go structs.
Think of client-go as a remote control for the cluster. You don't need to know the infrared frequencies or the button codes. You just press "Volume Up," and the remote handles the signal. In Go terms, you press clientset.CoreV1().Pods("default").Get(...), and client-go handles the HTTP, the auth tokens, and the JSON marshaling.
The library is split into packages. kubernetes provides the typed clientset for standard resources. discovery lets you query the API server for available groups and versions. rest handles the low-level HTTP transport. clientcmd loads kubeconfig files. dynamic provides an unstructured client for custom resources.
When you create a client, you start with a rest.Config. This struct holds the API server URL, the CA certificate, and the authentication credentials. You can build this config from a kubeconfig file or from the in-cluster environment. Once you have the config, you create a clientset. The clientset is a collection of typed clients organized by API group and version.
Run gofmt on your code. The community expects standard formatting. Most editors run it on save. Don't argue about indentation; let the tool decide.
The API server is the source of truth. Your client is just a messenger.
Minimal example: listing pods locally
Here's the simplest way to list pods from your local machine. The code loads your kubeconfig, creates a client, and fetches the pod list.
package main
import (
"context"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/util/homedir"
)
func main() {
// BuildConfigFromFlags loads the kubeconfig file and returns a REST config.
// This config contains the API server address and auth credentials.
config, err := clientcmd.BuildConfigFromFlags("", fmt.Sprintf("%s/.kube/config", homedir.HomeDir()))
if err != nil {
panic(err)
}
// NewForConfig creates a typed clientset.
// This provides ergonomic access to standard Kubernetes resources.
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
panic(err)
}
// List pods in the default namespace.
// context.Background() enables timeout and cancellation support.
pods, err := clientset.CoreV1().Pods("default").List(context.Background(), metav1.ListOptions{})
if err != nil {
panic(err)
}
fmt.Printf("Found %d pods\n", len(pods.Items))
}
Load the config, build the client, call the method. Keep the plumbing separate from the logic.
Walking through the call chain
The code starts with clientcmd.BuildConfigFromFlags. This function reads the kubeconfig file and returns a rest.Config. The kubeconfig file contains clusters, users, and contexts. The function resolves the current context and extracts the API server address and credentials. If the file is missing or malformed, the function returns an error.
Next, kubernetes.NewForConfig creates a clientset. The clientset implements kubernetes.Interface. This interface defines methods for every API group. CoreV1() returns a client for the core/v1 group. This group contains fundamental resources like Pods, Services, and Namespaces.
The chain clientset.CoreV1().Pods("default") narrows the scope. CoreV1() gets the group client. Pods("default") gets the namespace-scoped pod client. The namespace argument is required for namespaced resources. If you omit it, the compiler rejects the call with a missing-argument error.
List sends the request. It takes a context and ListOptions. The context supports cancellation and timeouts. ListOptions lets you filter results by labels, fields, or resource version. You can pass a LabelSelector to get only pods with specific labels. The function returns a PodList struct. The Items field holds the slice of pods.
context.Context is the first parameter for every API method. This is a hard rule. The context allows the caller to cancel the request or enforce a deadline. If you pass context.Background(), the request runs until completion or error. In long-running operations, use context.WithTimeout or context.WithCancel to prevent goroutine leaks.
Context is plumbing. Run it through every API call.
Realistic example: creating a pod in-cluster
When your code runs inside a pod, you don't need a kubeconfig file. Kubernetes mounts a service account token and the CA certificate into the pod's filesystem. It also sets environment variables for the API server address. rest.InClusterConfig reads these mounts and variables to build the config.
// InClusterConfig reads the service account token mounted in the pod.
// It also discovers the API server address from the environment.
config, err := rest.InClusterConfig()
if err != nil {
panic(err)
}
// NewForConfig builds the typed clientset.
// This clientset uses the pod's service account for authentication.
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
panic(err)
}
The InClusterConfig function checks for the mounted token file and the API server environment variable. If either is missing, it returns an error. This happens if you run the code locally without mocking the environment. The error message mentions missing environment variables or file not found.
Once you have the client, you can create resources. The code defines a Pod struct with ObjectMeta and Spec. ObjectMeta sets the name and namespace. The namespace must be explicit. The API server doesn't infer the namespace from the client scope. Spec defines the containers.
// Define the Pod object with metadata and container spec.
// Namespace must be explicit; the API server won't guess it.
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "busybox", Image: "busybox"},
},
},
}
// Create sends the object to the API server.
// The server validates the spec and returns the created object.
_, err = clientset.CoreV1().Pods("default").Create(context.Background(), pod, metav1.CreateOptions{})
if err != nil {
panic(err)
}
Create sends the object to the API server. The server validates the spec, checks RBAC permissions, and persists the object. It returns the created object with server-side defaults and resource version. If the name already exists, the server returns a AlreadyExists error.
In-cluster config is automatic. RBAC is not. Check your permissions before blaming the code.
Pitfalls and runtime errors
RBAC is the most common blocker. You can write perfect code and still get forbidden errors. The service account running your pod needs a Role and RoleBinding that grants the required verbs on the resource. If you try to list pods and get pods is forbidden: User "system:serviceaccount:default:my-app" cannot list resource "pods", the code is fine. The permissions are wrong.
Context leaks are dangerous. If you spawn a goroutine to watch resources and forget to cancel the context, the goroutine hangs forever. The watch channel stays open, and the connection leaks. Always defer cancel() when you create a context with context.WithCancel.
Resource version conflicts happen on updates. Kubernetes uses optimistic concurrency control. Every object has a ResourceVersion. When you update an object, you must include the current resource version. If another client modified the object in the meantime, the server rejects your update with a Conflict error. You need to fetch the latest version and retry.
Rate limiting protects the API server. client-go includes a built-in rate limiter. If you send too many requests, the client backs off automatically. You can configure the rate limiter in the rest.Config. If you see 429 Too Many Requests, the server is throttling you. The client retries, but you should reduce your request rate.
client-go returns errors for almost everything. The pattern if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't swallow errors with _. If a Create fails, you need to know why. The error message often contains the HTTP status code and the server response.
RBAC blocks the request before the code runs. Verify roles before debugging logic.
When to use which client
Use a typed client when you work with standard resources like Pods, Deployments, or Services. The compiler checks your field names and types, which catches mistakes early.
Use a dynamic client when you interact with Custom Resource Definitions or resources that aren't in the standard API groups. The dynamic client works with unstructured JSON maps, so you don't need generated code for every new CRD.
Use clientcmd when building tools for local development or CLI utilities that need to respect the user's kubeconfig. This lets your tool work alongside kubectl without extra configuration.
Use InClusterConfig when your application runs as a pod inside the cluster. It automatically picks up the service account credentials and API server address.
Use a REST client when you need low-level control over HTTP requests or are accessing sub-resources that typed clients don't expose directly. This is rare; typed clients cover most use cases.
Typed clients for safety. Dynamic clients for flexibility. Pick the tool that matches your resource.