PersistentVolumes & PersistentVolumeClaims
An emptyDir volume survives container restarts but disappears when the pod is deleted. For data that must outlive any pod — databases, uploaded files, queues — you need storage that exists independently of the pod's lifecycle. That's what PersistentVolumes (PV) and PersistentVolumeClaims (PVC) provide: a clean separation between how storage is provisioned (the cluster admin's job) and how it's consumed (the developer's job).
Why PVCs?
Without PVCs, developers would reference storage backends directly in their pod specs — hardcoding a specific AWS EBS volume ID, a GCP disk name, or an NFS server address. Moving between environments or cloud providers means editing every pod spec. PVCs decouple the request (I need 10Gi of fast block storage) from the implementation (this is the specific AWS EBS volume that satisfies it). The binding is done by Kubernetes, not the developer.
PersistentVolume Spec
A PV is a cluster-scoped resource that represents a piece of real storage — an NFS mount, a cloud disk, a local directory. It's created by a cluster admin (or dynamically by a provisioner) and exists independently of any pod.
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-postgres-data
spec:
capacity:
storage: 50Gi
accessModes:
- ReadWriteOnce # see Access Modes below
persistentVolumeReclaimPolicy: Retain # see Reclaim Policies below
storageClassName: fast-ssd
# The actual storage backend — one of many options:
awsElasticBlockStore:
volumeID: vol-0a1b2c3d4e5f
fsType: ext4
# Other backends: nfs, hostPath, csi, gcePersistentDisk, azureDisk, etc.
Access Modes
Access modes declare how the volume can be mounted across nodes. The mode you can use depends on the storage backend — not all backends support all modes.
| Mode | Short | Meaning | Supported by |
|---|---|---|---|
ReadWriteOnce | RWO | Mounted read-write by one node at a time | Block storage (EBS, PD, Azure Disk, local disk) |
ReadOnlyMany | ROX | Mounted read-only by many nodes simultaneously | NFS, some cloud file systems |
ReadWriteMany | RWX | Mounted read-write by many nodes simultaneously | NFS, CephFS, Azure Files — not EBS or GCP PD |
ReadWriteOncePod | RWOP | Mounted read-write by one pod (stricter than RWO) | CSI volumes (k8s 1.22+) |
ReadWriteOnce limits mounting to one node, not one pod. Multiple pods on the same node can mount an RWO volume simultaneously — this can cause data corruption for databases that assume exclusive access. Use ReadWriteOncePod for true single-writer guarantees.
Reclaim Policies
The reclaim policy controls what happens to the PV when the PVC that claimed it is deleted:
| Policy | What happens | When to use |
|---|---|---|
Retain | PV becomes Released — data preserved, manual cleanup required | Production databases; data must not be accidentally deleted |
Delete | PV and the underlying storage resource are deleted automatically | Dynamic provisioning; transient data; dev/test |
Recycle | Volume is scrubbed (rm -rf) and made available again | Deprecated — use dynamic provisioning instead |
PersistentVolumeClaim Spec
A PVC is a namespace-scoped request for storage. The developer writes the PVC; Kubernetes finds a matching PV and binds them together.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: production
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi # minimum size needed
storageClassName: fast-ssd # must match a PV or StorageClass
# volumeName: pv-postgres-data # optional: bind to a specific PV by name
The Binding Lifecycle
Matching rules — a PV satisfies a PVC when:
storageClassNamematches (or both are empty)accessModeson the PV include all modes requested by the PVC- PV capacity is at least the PVC's requested size (the PVC gets the whole PV, not just its requested slice)
- Any
selectoron the PVC matches the PV's labels
Using a PVC in a Pod
apiVersion: v1
kind: Pod
metadata:
name: postgres
namespace: production
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data # the PVC name in the same namespace
containers:
- name: postgres
image: postgres:16
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
The pod stays Pending until the PVC is Bound. The PVC stays Pending until a matching PV is available. Trace the chain when debugging a stuck pod.
Dynamic Provisioning
With static provisioning, an admin must pre-create PVs. With dynamic provisioning, a StorageClass tells Kubernetes how to automatically create a PV when a PVC is submitted — no manual PV creation needed. This is the standard approach on cloud clusters (see the StorageClasses guide).
# PVC with dynamic provisioning — no pre-existing PV needed
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: production
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
storageClassName: standard # references a StorageClass
# Kubernetes calls the StorageClass provisioner, which creates an EBS volume,
# creates a PV for it, and binds the PVC automatically.
Debugging Stuck Pending
A PVC stuck in Pending is one of the most common Kubernetes storage problems. Work through this checklist:
# 1. Check the PVC status and events
kubectl describe pvc postgres-data -n production
# Look for: "no persistent volumes available" or "did not find matching PV"
# 2. List available PVs and check their status
kubectl get pv
# STATUS=Available means unbound, STATUS=Bound means already claimed
# 3. Check if storageClassName matches between PVC and PV
kubectl get pvc postgres-data -o jsonpath='{.spec.storageClassName}'
kubectl get pv pv-postgres -o jsonpath='{.spec.storageClassName}'
# 4. Check capacity — PV must be >= PVC request
kubectl get pv pv-postgres -o jsonpath='{.spec.capacity.storage}'
# 5. Check accessModes — PV modes must include all PVC modes
kubectl get pv pv-postgres -o jsonpath='{.spec.accessModes}'
# 6. If using dynamic provisioning, check the StorageClass exists
kubectl get storageclass
# 7. Check if the provisioner pod is running
kubectl get pods -n kube-system | grep provisioner
After a PVC is deleted, a PV with Retain policy enters Released state — not Available. It holds a reference to the old claim and won't bind to a new PVC until you manually remove the spec.claimRef field. This trips up many users who delete a PVC expecting the PV to become available automatically.
kubectl Commands
# List PVs (cluster-wide)
kubectl get pv
# List PVCs in a namespace
kubectl get pvc -n production
# Detailed view — shows bound PV, capacity, access modes, phase
kubectl describe pvc postgres-data -n production
# Watch PVC phase change from Pending to Bound
kubectl get pvc -n production -w
# Release a Retained PV for reuse (remove claimRef)
kubectl patch pv pv-postgres -p '{"spec":{"claimRef":null}}'
# Delete a PVC (data kept if policy=Retain)
kubectl delete pvc postgres-data -n production
# Resize a PVC (StorageClass must allow expansion)
kubectl patch pvc postgres-data -n production \
-p '{"spec":{"resources":{"requests":{"storage":"100Gi"}}}}'