Kubernetes StorageClasses

By Vishvesh Patel · DevOps EngineerUpdated September 12, 2026

Dynamic volume provisioning with StorageClasses. Provisioners, parameters, reclaim policy, volumeBindingMode, expansion, and setting a sensible default.

A StorageClass describes a kind of storage the cluster can create on demand. A PersistentVolumeClaim names one, and a provisioner creates a matching volume automatically.

Without StorageClasses an administrator has to pre-create every PersistentVolume by hand. With them, an application team writes a claim and gets a disk.

What one looks like

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  iops: "3000"
  throughput: "125"
  encrypted: "true"
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
kubectl get storageclass
NAME            PROVISIONER        RECLAIMPOLICY  VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION
gp3 (default)   ebs.csi.aws.com    Delete         WaitForFirstConsumer   true
gp3-retain      ebs.csi.aws.com    Retain         WaitForFirstConsumer   true
efs             efs.csi.aws.com    Delete         Immediate              false

Using it is a single line in the claim:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: payments-data
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: gp3
  resources:
    requests:
      storage: 20Gi

The fields that matter

provisioner

Which CSI driver creates the volume, ebs.csi.aws.com, disk.csi.azure.com, pd.csi.storage.gke.io, efs.csi.aws.com, and so on.

The driver must actually be installed. A StorageClass naming a provisioner that is not present leaves claims Pending forever with an event saying no provisioner is available. This is the most common cause of a PVC that never binds on a self-managed cluster:

kubectl get csidrivers

parameters

Provisioner-specific and completely unportable. AWS gp3 parameters mean nothing to the Azure driver. Check the driver's own documentation rather than copying between clouds.

encrypted: "true" is worth making the default. Encryption at rest costs nothing on most providers and cannot be turned on after the volume exists.

reclaimPolicy

What happens to the underlying disk when the PVC is deleted.

ValueEffect
DeleteThe volume and its data are destroyed. The default for dynamic provisioning
RetainThe PV and data survive; an administrator handles it

Delete is the default, and it means deleting a namespace can permanently destroy a database volume. A practical pattern is to offer two classes and use the retaining one for anything stateful:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3-retain
provisioner: ebs.csi.aws.com
parameters:
  type: gp3
  encrypted: "true"
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

The trade-off is that retained volumes accumulate and keep costing money, so someone has to clean them up deliberately.

volumeBindingMode

The field most worth understanding, because its default behaviour produces a confusing symptom.

ModeBehaviour
ImmediateProvision the volume as soon as the PVC is created
WaitForFirstConsumerWait until a Pod using the claim is scheduled

With Immediate on a multi-zone cluster, the volume is created in some zone before anyone knows where the Pod will run. If the scheduler later picks a node in a different zone, the Pod cannot mount it and sits Pending with a volume node affinity conflict.

WaitForFirstConsumer defers provisioning until the scheduler has chosen a node, so the disk is created in the right zone. Use it for any zonal block storage.

The side effect is an event that looks like a problem and is not:

Normal  WaitForFirstConsumer  waiting for first consumer to be created before binding

A PVC sitting Pending with that message is working correctly. It binds as soon as a Pod uses it.

Immediate remains appropriate for genuinely zone-independent storage such as EFS or NFS.

allowVolumeExpansion

Must be true before a PVC can grow:

kubectl patch pvc payments-data -p '{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}'

Shrinking is never supported by any provisioner. Enable expansion on every class you create. There is no downside, and turning it on later does not help volumes that already exist.

The default StorageClass

A PVC with no storageClassName uses whichever class is annotated as default:

kubectl patch storageclass gp3 \
  -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Two things to watch. Exactly one class should be default, with several, behaviour is unpredictable. And storageClassName: "" is not the same as omitting the field: an empty string explicitly disables dynamic provisioning and forces binding to a pre-created PV.

Managed clusters ship a default, and it is rarely the best choice. On EKS it has historically been gp2, which is slower and more expensive than gp3 for most workloads. Checking and replacing it is a quick win.

Snapshots

CSI drivers that support it provide volume snapshots, which is the real backup mechanism for Kubernetes storage:

apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
  name: ebs-snapshots
driver: ebs.csi.aws.com
deletionPolicy: Delete
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: payments-data-2026-09-12
spec:
  volumeSnapshotClassName: ebs-snapshots
  source:
    persistentVolumeClaimName: payments-data

A snapshot can then be restored into a new PVC via dataSource. reclaimPolicy: Retain is not a backup. It only helps if the PVC is deleted, and does nothing for corruption or accidental writes.

Debugging

kubectl describe pvc payments-data
kubectl get storageclass
kubectl get csidrivers
kubectl logs -n kube-system -l app=ebs-csi-controller -c csi-provisioner --tail=50
SymptomCause
no persistent volumes available... and no storage class is setNo default class, and the PVC named none
waiting for first consumerWaitForFirstConsumer working normally
no volume plugin matchedThe named provisioner's CSI driver is not installed
volume node affinity conflictImmediate binding put the volume in the wrong zone
Expansion ignoredallowVolumeExpansion is false on the class

Quick reference

TaskCommand
List classeskubectl get storageclass
Installed driverskubectl get csidrivers
Set the defaultkubectl patch storageclass <n> -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
Why is a PVC pendingkubectl describe pvc <n>
Expand a volumekubectl patch pvc <n> -p '{"spec":{"resources":{"requests":{"storage":"50Gi"}}}}'
Which class a PV useskubectl get pv <n> -o jsonpath='{.spec.storageClassName}'

Key takeaways

  • A StorageClass plus a PVC gives you a disk on demand: no pre-created PersistentVolumes.
  • The named provisioner's CSI driver must be installed, or claims stay Pending forever.
  • reclaimPolicy: Delete is the default and will destroy data with the namespace. Offer a Retain class for stateful workloads.
  • Use WaitForFirstConsumer for zonal block storage, and do not mistake its "waiting for first consumer" event for an error.
  • Set allowVolumeExpansion: true on every class. It cannot be applied retroactively to existing volumes.
  • Exactly one default class, and check what your managed cluster shipped. It is often not the best option.
  • Volume snapshots are the backup mechanism. Retain is not a backup.

Check your understanding

Exercise

A volume in the wrong zone

A StorageClass uses volumeBindingMode: Immediate on a three-zone cluster. A PVC binds instantly, but the Pod using it stays Pending with "volume node affinity conflict". What happened?

Exercise

Data lost with a namespace

A team deletes a staging namespace to clean up. The Postgres data volume is gone permanently. Which StorageClass setting caused this?


Frequently Asked Questions

What is a StorageClass in Kubernetes?

It is a cluster-scoped object describing a type of storage that can be created on demand, which CSI driver provisions it, what parameters that driver should use, what happens to the disk when the claim goes away, and when provisioning should happen. A PersistentVolumeClaim names a StorageClass and the provisioner creates a matching PersistentVolume automatically, which removes the need for an administrator to pre-create volumes by hand.

What does volumeBindingMode: WaitForFirstConsumer do?

It defers creating the volume until a Pod using the claim has been scheduled, so the disk is provisioned in the same Availability Zone as the node that will mount it. With Immediate, the volume is created straight away in some arbitrary zone, and if the scheduler later places the Pod elsewhere the Pod is stuck with a volume node affinity conflict. Use WaitForFirstConsumer for any zonal block storage, and do not mistake its "waiting for first consumer" event for an error. It is the mode working as intended.

Why is my PVC stuck in Pending with a StorageClass set?

Check kubectl describe pvc for the exact event. "Waiting for first consumer" is normal and resolves once a Pod uses the claim. "No volume plugin matched" or a missing provisioner means the CSI driver named by the StorageClass is not installed. Confirm with kubectl get csidrivers. Otherwise look at the provisioner's own logs, since quota limits, permissions and unsupported parameter combinations all surface there rather than on the claim.

How do I set or change the default StorageClass?

Add the annotation storageclass.kubernetes.io/is-default-class: "true" to the class you want, and remove it from any other. Having two defaults makes the behaviour unpredictable. Claims that omit storageClassName then use it. Be aware that storageClassName: "" is not the same as leaving the field out: an empty string explicitly disables dynamic provisioning and forces binding to a pre-created PersistentVolume.

How do I stop deleting a namespace from destroying my data?

Create a second StorageClass with reclaimPolicy: Retain and use it for anything stateful. With Delete, which is the default on nearly every cloud StorageClass, removing a PVC destroys the underlying disk, and deleting a namespace removes every PVC in it. Retain keeps the volume and its data, leaving the PV in a Released state that an administrator can recover. Retained volumes do accumulate cost, so they need deliberate cleanup, and real volume snapshots remain the actual backup mechanism.

Can I change a StorageClass after volumes have been created?

Most fields on a StorageClass are immutable, and in any case changes do not apply retroactively to existing PersistentVolumes. They were provisioned with the settings in force at the time. To move a workload onto different storage you create a new StorageClass, create a new PVC using it, and migrate the data, typically from a volume snapshot. This is why enabling allowVolumeExpansion and encryption from the beginning matters: neither can be added to a volume that already exists.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Kubernetes Playground

Practise kubectl against a simulated cluster in your browser.

When it breaks

Related Resources