Symptoms
You've deployed a workload that needs persistent storage, and the pod is stuck in Pending state. When you describe the pod, you see something like this:
Events:
Warning FailedScheduling 30s default-scheduler 0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims.Or you look directly at the PVC and it's sitting in Pending instead of Bound:
$ kubectl get pvc -n production
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
app-data-claim Pending fast-ssd 8mThe pod won't schedule, the workload is down, and you need to figure out why that PVC won't bind. This article walks through every common reason a PersistentVolumeClaim stays stuck in Pending and shows you exactly how to diagnose and fix each one.
How PV Binding Works
Before diving into causes, it helps to understand what Kubernetes is actually doing when it tries to bind a PVC. When you create a PVC, the control plane runs a binding loop that looks for a compatible PersistentVolume. For a match to succeed, the PV must satisfy all of the following: the StorageClass must match, the access modes must be compatible, the capacity must be sufficient, and the PV must not already be bound or reserved. If dynamic provisioning is in play, the StorageClass provisioner handles creating the PV automatically — but if the provisioner can't be found or fails, you end up in the same Pending state.
Understanding this binding logic makes it much easier to narrow down what's wrong. Always start with
kubectl describe pvc <name>— the Events section at the bottom usually tells you exactly which condition failed.
Root Cause 1: StorageClass Not Found
This one catches people off guard more often than you'd expect. The PVC references a StorageClass by name, but that StorageClass either doesn't exist in the cluster, was deleted, or was never created in the first place.
Why does this happen? Usually it's a copy-paste from a manifest that worked in a different cluster — maybe staging had a StorageClass called
fast-ssdand production has one called
gp3-encrypted. Or the StorageClass was part of a Helm chart that wasn't installed. In my experience, this also comes up frequently after cluster migrations where the new cluster has a different set of provisioners and nobody updated the application manifests.
To identify it, check what StorageClasses actually exist:
$ kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE AGE
standard (default) kubernetes.io/no-provisioner Delete Immediate 45d
gp3-encrypted ebs.csi.aws.com Delete WaitForFirstConsumer 45dNow compare that against what your PVC is requesting:
$ kubectl describe pvc app-data-claim -n production
Name: app-data-claim
Namespace: production
StorageClass: fast-ssd
Status: Pending
...
Events:
Warning ProvisioningFailed 2m persistentvolume-controller storageclass.storage.k8s.io "fast-ssd" not foundThere it is. The StorageClass
fast-ssddoesn't exist. The fix is straightforward: either update the PVC to reference a StorageClass that actually exists, or create the missing one. Here's an example for an EBS-backed class:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
type: gp3
encrypted: "true"
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: DeleteIf you're working with a static PV instead of dynamic provisioning, both the PVC and PV must specify the same StorageClass name, or both must omit it entirely. A PVC with no StorageClass will match a PV with no StorageClass — but a PVC with an explicit name and a PV with a different name simply won't bind, no matter how compatible they are in every other way.
Root Cause 2: No Matching PV (Static Provisioning)
When you're not using dynamic provisioning, you need a pre-created PV that matches the PVC. If no such PV exists — or all existing PVs have already been consumed — the PVC sits in Pending indefinitely.
This comes up most often in on-premises clusters where storage isn't infinitely elastic. An engineer provisions a handful of PVs manually, the cluster grows, a new namespace gets added, and there simply aren't enough PVs to go around. Identify it by listing all PVs and their status:
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE
pv-data-1 10Gi RWO Retain Bound production/db-claim local-storage 30d
pv-data-2 10Gi RWO Retain Bound production/cache-claim local-storage 30d
pv-data-3 5Gi RWO Retain Available local-storage 30dHere
pv-data-3is Available, but it's only 5Gi. If the PVC is requesting 10Gi, this PV won't match. The fix is to create a PV at the right size. For a local volume on node
sw-infrarunbook-01:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-data-4
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/disks/ssd1
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- sw-infrarunbook-01After applying this, the binding loop will pick it up within seconds if all other criteria match the pending PVC.
Root Cause 3: Access Mode Mismatch
Access modes are one of the trickier aspects of PV binding because the names sound intuitive but the semantics have sharp edges. Kubernetes supports three access modes: ReadWriteOnce (RWO) allows mounting read-write by a single node, ReadOnlyMany (ROX) allows read-only mounting by multiple nodes, and ReadWriteMany (RWX) allows read-write mounting by multiple nodes.
A PVC requesting
ReadWriteManywill not bind to a PV that only supports
ReadWriteOnce, even if the capacity and StorageClass match perfectly. The other common trap: not all storage backends support all access modes. EBS volumes only support RWO. NFS and CephFS support RWX. If you're requesting RWX on an EBS-backed StorageClass, you'll never get a binding — the provisioner either fails or produces a volume that won't satisfy the access mode requirement.
Identify the mismatch by comparing the PVC spec against available PVs:
$ kubectl describe pvc shared-data-claim -n production
Name: shared-data-claim
Namespace: production
StorageClass: local-storage
Status: Pending
Access Modes: RWX
$ kubectl get pv pv-data-4 -o yaml | grep -A5 accessModes
accessModes:
- ReadWriteOnceThe PVC wants RWX, the PV offers RWO. They won't match. The fix depends on what your application actually needs. If it genuinely requires shared access across multiple nodes, you need a storage backend that supports RWX — NFS, CephFS, Azure Files, and EFS are common choices. If the application doesn't actually need multi-node access, change the PVC to request
ReadWriteOnce. Don't just change the access mode to make it bind without confirming the application's actual I/O pattern.
Root Cause 4: Capacity Mismatch
Kubernetes uses capacity as part of the binding decision. A PVC requesting 20Gi will not bind to a PV that offers only 10Gi. The reverse is acceptable — a PVC requesting 10Gi can bind to a 20Gi PV, though it wastes capacity. This asymmetry trips people up.
I've seen clusters where someone created a 9Gi PV but the PVC asked for 10Gi, and the binding loop silently ignored the match for hours because nobody thought to check sizes. The describe output won't always scream at you — sometimes it just says no volumes are available.
$ kubectl describe pvc app-data-claim -n production
Name: app-data-claim
Namespace: production
StorageClass: local-storage
Status: Pending
Events:
Normal FailedBinding 3m persistentvolume-controller no persistent volumes available for this claim and no storage class is set
$ kubectl get pv
NAME CAPACITY ACCESS MODES STATUS STORAGECLASS
pv-data-3 9Gi RWO Available local-storageThe PVC needs 10Gi, the only available PV is 9Gi. The fix is to create a PV at the correct size or, if you have flexibility in the PVC spec, adjust it downward to match what's available. For dynamic provisioning, a capacity mismatch usually means a quota issue or a misconfigured StorageClass — check your cloud provider's volume size minimums and maximums as well, since some providers enforce minimum sizes of 1Gi, 4Gi, or even 20Gi depending on volume type.
If you're troubleshooting under pressure and the PVC hasn't been used yet, you can delete and recreate it at the correct size:
$ kubectl delete pvc app-data-claim -n production
$ kubectl apply -f pvc-corrected.yamlNever delete a PVC that's currently mounted by a running pod. Drain the workload first.
Root Cause 5: PV Already Bound to Another PVC
A PV in
Boundstatus is exclusive — it belongs to exactly one PVC at a time. If the PV you're targeting is already claimed, your new PVC won't get it. This sounds obvious, but there's a subtler version of this problem worth knowing: a PV in
Releasedstate.
When a PVC is deleted, the PV transitions to
Released. The data is still there, the volume still exists, but Kubernetes won't automatically rebind it to a new PVC. This is by design — it protects you from accidentally binding a PV that contains data from a previous workload. But it also means a PV can look almost-available and still refuse to bind.
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS
pv-data-1 10Gi RWO Retain Released production/old-app-claim local-storageThat
Releasedstatus means the PV still has its old
claimRefpopulated. A new PVC can't claim it until you clear that reference. Here's how to fix it:
$ kubectl patch pv pv-data-1 --type json -p '[{"op": "remove", "path": "/spec/claimRef"}]'
persistentvolume/pv-data-1 patched
$ kubectl get pv pv-data-1
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS STORAGECLASS
pv-data-1 10Gi RWO Retain Available local-storageAfter removing the
claimRef, the PV returns to
Availableand can match a new PVC. Be intentional here — only do this after you've confirmed the previous data is either no longer needed or has been backed up elsewhere.
For the case where the PV is actively
Boundto a different PVC in a different namespace:
$ kubectl describe pv pv-data-1 | grep -E "Status|Claim"
Status: Bound
Claim: staging/other-app-claimThat PV is owned by the
stagingnamespace. Your only real options are to wait for that PVC to be deleted, create a new PV, or switch to dynamic provisioning so the cluster stops requiring you to manage this manually.
Root Cause 6: VolumeBindingMode Set to WaitForFirstConsumer
This one is increasingly common as more teams adopt topology-aware storage. When a StorageClass has
volumeBindingMode: WaitForFirstConsumer, the PV is not provisioned or bound until a pod that uses the PVC is actually scheduled. The PVC will sit in Pending until then — and this is expected behavior, not a bug.
$ kubectl get storageclass gp3-encrypted -o yaml | grep volumeBindingMode
volumeBindingMode: WaitForFirstConsumer
$ kubectl get pvc app-data-claim -n production
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
app-data-claim Pending gp3-encrypted 2mIf the PVC is Pending but no pod referencing it has been created yet, this is exactly what's supposed to happen. Create the pod, and the PVC will bind as part of scheduling. If a pod already references the PVC and that pod is also stuck in Pending, the problem is likely at the node level — check pod events:
$ kubectl describe pod app-pod -n production | tail -20
Events:
Warning FailedScheduling 45s default-scheduler 0/3 nodes are available: 1 node(s) had volume node affinity conflict, 2 node(s) didn't match Pod's node affinity/selector.That "volume node affinity conflict" message is your signal. The provisioner created the volume in one availability zone and your pod's nodeSelector or affinity rules point at a different zone. Aligning them resolves the binding.
Root Cause 7: Label Selector Mismatch on PVC
PVCs support a
selectorfield that filters which PVs are eligible for binding. If you specify a selector that doesn't match any available PV's labels, the PVC stays Pending even when PVs exist with matching capacity, access modes, and StorageClass. This is easy to overlook because the selector isn't always visible in a quick
kubectl get pvc.
$ kubectl describe pvc app-data-claim -n production
Selector: environment=production,tier=database
$ kubectl get pv --show-labels
NAME CAPACITY STATUS LABELS
pv-data-3 10Gi Available environment=staging,tier=databaseThe PV has
environment=stagingbut the PVC requires
environment=production. To fix it, relabel the PV if appropriate or remove the selector from the PVC if it isn't providing real filtering value:
$ kubectl label pv pv-data-3 environment=production --overwrite
persistentvolume/pv-data-3 labeledPrevention
Most PV binding failures are preventable with a few consistent practices.
Default to dynamic provisioning wherever your infrastructure supports it. The entire class of "no matching PV" and "PV already bound" problems disappears when the cluster provisions volumes on demand. Keep a well-tested, well-documented StorageClass as the default in every cluster, and make sure the CSI driver backing it is healthy — a broken provisioner is often the silent cause behind a wave of Pending PVCs.
If you're using static PVs, maintain an inventory and run automated checks before deployments go out. A simple pre-deploy validation that queries available PVs at the expected size can catch shortfalls before workloads are affected:
$ kubectl get pv -o json | jq '[.items[] | select(.status.phase == "Available") | {name: .metadata.name, capacity: .spec.capacity.storage}]'Use consistent StorageClass naming across environments. If staging uses a class called
fast-ssd, production should have a class with the same name even if the underlying provisioner differs. This prevents the all-too-common scenario where manifests work perfectly in staging and fail silently in production due to StorageClass name drift.
For PVs with a Retain reclaim policy, build a process to handle Released volumes. Either automate the
claimRefcleanup after data verification, or switch to the Delete reclaim policy for workloads where preserving data after PVC deletion isn't required.
Document access mode requirements explicitly in your application manifests and validate them against your storage backend's capabilities during design and review — not after deployment. If a backend doesn't support RWX, catch that at the architecture stage, not at 2am when a deployment fails.
Finally, set up monitoring on PVC status. A PVC stuck in Pending for more than a few minutes should trigger an alert. With Prometheus and kube-state-metrics, the query is straightforward:
kube_persistentvolumeclaim_status_phase{phase="Pending"} == 1Catching a stuck PVC within minutes of creation is far better than finding out an hour later when a critical workload fails to start. Combined with a clear runbook — like this one — your team can resolve binding failures quickly and consistently without escalating every incident.
