Storage

PersistentVolumes & PersistentVolumeClaims

● Intermediate ⏱ 15 min read

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.

persistent-volume.yaml
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.

ModeShortMeaningSupported by
ReadWriteOnceRWOMounted read-write by one node at a timeBlock storage (EBS, PD, Azure Disk, local disk)
ReadOnlyManyROXMounted read-only by many nodes simultaneouslyNFS, some cloud file systems
ReadWriteManyRWXMounted read-write by many nodes simultaneouslyNFS, CephFS, Azure Files — not EBS or GCP PD
ReadWriteOncePodRWOPMounted read-write by one pod (stricter than RWO)CSI volumes (k8s 1.22+)
⚠️
RWO allows multiple pods on the same node

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:

PolicyWhat happensWhen to use
RetainPV becomes Released — data preserved, manual cleanup requiredProduction databases; data must not be accidentally deleted
DeletePV and the underlying storage resource are deleted automaticallyDynamic provisioning; transient data; dev/test
RecycleVolume is scrubbed (rm -rf) and made available againDeprecated — 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.

persistent-volume-claim.yaml
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

PV/PVC binding lifecycle
PersistentVolume (cluster admin)
phase: Available
pv-postgres · 50Gi
storageClass: fast-ssd · RWO
↓ matched & bound
phase: Bound
pv-postgres · 50Gi
claimRef: production/postgres-data
bind
PersistentVolumeClaim (developer)
phase: Pending
postgres-data · 20Gi
storageClass: fast-ssd · RWO
↓ k8s control loop
phase: Bound
postgres-data
volumeName: pv-postgres
pod mounts the PVC by name
volumes: [{name: data, persistentVolumeClaim: {claimName: postgres-data}}]
The PVC stays Pending until a PV with matching storageClass, accessMode, and capacity is found
Kubernetes matches a PVC to an available PV by storageClassName, accessModes, and capacity. Both transition to Bound simultaneously.

Matching rules — a PV satisfies a PVC when:

Using a PVC in a Pod

pod-with-pvc.yaml
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
💡
A Bound PV can't be reused until it's Released

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"}}}}'