Kubernetes StatefulSets Deep Dive
Stable network identity, ordered rollout, and per-replica persistent storage - how StatefulSets differ from Deployments and when to use them
Introduction
A Deployment’s pods are interchangeable - any replica can be replaced by any other, with a new random suffix, no persistent identity, and no guaranteed order. A StatefulSet exists for workloads where that’s not true: each replica needs a stable, predictable name, its own persistent storage that follows it across rescheduling, and (optionally) ordered startup/shutdown. Databases, message queues, and anything doing peer discovery by hostname are the usual candidates.
What’s Actually Different
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
clusterIP: None # headless - no load-balancing, just DNS records per pod
selector:
app: postgres
ports:
- port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres # must match the headless Service above
replicas: 3
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16
ports:
- containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-credentials
key: password
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
Three things this gets you that a Deployment can’t:
- Stable names: pods are named
postgres-0,postgres-1,postgres-2- not random suffixes - and keep that exact name across restarts and rescheduling. - Stable network identity: the headless Service (
clusterIP: None) gives each pod its own DNS record,postgres-0.postgres.<namespace>.svc.cluster.local, resolvable individually rather than only load-balanced as a group - essential for peer discovery in clustered databases that need to address specific members. - Per-replica persistent storage:
volumeClaimTemplatesprovisions a separatePersistentVolumeClaimfor each replica (data-postgres-0,data-postgres-1,data-postgres-2), and each pod always reattaches to its own claim -postgres-1restarting gets its own data back, never another replica’s.
Ordering Guarantees
By default, a StatefulSet creates pods sequentially (postgres-0 must be Running and Ready before postgres-1 starts) and deletes them in strict reverse order on scale-down - correct for most clustered systems where a new member needs to find an already-running one to join, but unnecessarily slow for workloads that don’t actually need it:
spec:
podManagementPolicy: Parallel # start/stop all replicas at once instead
Parallel trades away startup/shutdown ordering for speed - reach for it only once you’ve confirmed the workload doesn’t depend on join-order (most caching layers and stateless-but-storage-backed workloads qualify; most consensus-based databases don’t).
Controlled Rolling Updates
spec:
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 1 # only replicas with ordinal >= 1 get updated
partition is StatefulSet-specific and has no Deployment equivalent: setting it to 1 on a 3-replica set updates only postgres-2 and postgres-1, leaving postgres-0 on the old version - a canary pattern for stateful workloads, where you’d want to verify a schema-migrating new image works correctly on a minority of replicas before rolling it out further by lowering partition toward 0.
Scaling Down and Storage
kubectl scale statefulset postgres --replicas=2
Scaling down deletes postgres-2 but not its PersistentVolumeClaim by default - the claim (and the underlying volume, depending on the StorageClass’s reclaim policy) survives, so scaling back up to 3 later reattaches postgres-2 to its original data rather than starting fresh. This is deliberate (accidental data loss from a scale-down typo would be far worse than a lingering unused volume), but it does mean cleaning up a StatefulSet you’re actually decommissioning requires deleting its PVCs separately:
kubectl delete pvc -l app=postgres
Kubernetes 1.27+ adds an optional persistentVolumeClaimRetentionPolicy field to automate this if you want scale-down or StatefulSet deletion to actually clean up claims:
spec:
persistentVolumeClaimRetentionPolicy:
whenScaled: Retain # keep PVCs on scale-down (the historical default behavior)
whenDeleted: Delete # but delete them when the whole StatefulSet is deleted
Best Practices
- Use a StatefulSet only when you actually need its guarantees - stable identity, ordering, or per-replica storage. A stateless API server behind a Deployment doesn’t benefit from any of this and pays for slower, ordered rollouts for nothing.
- Set
persistentVolumeClaimRetentionPolicydeliberately rather than relying on the historical default - decide explicitly whether scale-down and deletion should keep or clean up claims for your specific workload. - Use
podManagementPolicy: Parallelunless the workload genuinely requires ordered join semantics - most StatefulSet workloads deployed today (search indexes, some caches) don’t, and sequential startup on a large replica count is a real deploy-time cost. - Prefer a purpose-built operator (like the ones in the operator pattern guide) over a hand-rolled StatefulSet for anything with real operational complexity (backups, failover, schema migrations) - most mature databases have one, and it encodes operational knowledge a bare StatefulSet manifest doesn’t.
Conclusion
StatefulSets solve a real, narrow problem - stable identity and storage for workloads that care about which specific replica they are - and add real cost (slower rollouts, manual PVC cleanup by default) that stateless workloads shouldn’t pay. Reach for a Deployment first, and only move to a StatefulSet once a workload’s requirements genuinely demand it.