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:

  1. Stable names: pods are named postgres-0, postgres-1, postgres-2 - not random suffixes - and keep that exact name across restarts and rescheduling.
  2. 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.
  3. Per-replica persistent storage: volumeClaimTemplates provisions a separate PersistentVolumeClaim for each replica (data-postgres-0, data-postgres-1, data-postgres-2), and each pod always reattaches to its own claim - postgres-1 restarting 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

  1. 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.
  2. Set persistentVolumeClaimRetentionPolicy deliberately rather than relying on the historical default - decide explicitly whether scale-down and deletion should keep or clean up claims for your specific workload.
  3. Use podManagementPolicy: Parallel unless 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.
  4. 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.

Additional Resources