Skip to content
BytePatterns

PV vs PVC vs StorageClass: Who Owns the Disk and How They Bind

7 min readBytePatterns

PV vs PVC vs StorageClass: the volume an admin or provisioner makes, the claim an app files, the class that provisions on demand, and the binding rules.

PV vs PVC vs StorageClass in one line: a PersistentVolume (PV) is a piece of real storage represented in the cluster, a PersistentVolumeClaim (PVC) is an app's request for storage of a given size, access mode and class, and a StorageClass is the recipe a provisioner follows to create a PV on demand when no existing one fits. Pods mount claims, never volumes. A control loop binds each claim to exactly one volume, and the class decides when that happens and what happens to the data afterwards. Facts come from the Kubernetes documentation listed at the end, as of October 2026.

ObjectWho writes itScope and job
PersistentVolumea cluster admin (static) or a provisioner (dynamic)cluster-wide; one real disk or share, with capacity, modes, reclaim policy
PersistentVolumeClaimthe app developer, or a StatefulSet's volumeClaimTemplatesnamespaced; "I need 10Gi, ReadWriteOnce, class fast"
StorageClassa cluster admincluster-wide; provisioner, parameters, reclaimPolicy, volumeBindingMode

The problem it solves

An app needs a disk that outlives its Pod. The app team knows how much space it needs and how it will be mounted, but not which cloud disk type, which zone or which NFS server to use. The platform team knows those things but not every app's needs. Three objects let each side write only what it knows: the developer files a claim, the admin publishes classes (or a stock of volumes), and Kubernetes matches them. The same Pod spec then works on any cluster that offers a matching class.

The intuition

Static provisioning: the admin stocks the shelf. An admin creates PVs ahead of time, each describing a real disk or share. A claim that fits is bound to one of them. The documentation's guarantee is that the claim gets at least what it asked for, possibly more: a 10Gi claim can land on a 20Gi volume, and the extra space is wasted for as long as the binding lasts.

Dynamic provisioning: the class makes one to order. When no existing PV fits, and the claim names a StorageClass with a provisioner, that provisioner creates a disk of the requested size and a PV for it, and the loop binds them. A claim that asks for class "" opts out: it only binds to PVs with no class and is never provisioned for. A claim that omits storageClassName gets the cluster's default class, the one annotated storageclass.kubernetes.io/is-default-class: "true".

Binding rules. A PV fits a claim when its class matches exactly, it offers every access mode the claim asks for, its capacity is at least the request, and it is not already bound. A selector on the claim adds a label match. Binding is one-to-one and exclusive, recorded in both directions through the PV's claimRef. A claim with no fit and no provisioner stays Pending indefinitely, and binds the moment a matching PV appears.

When it binds: volumeBindingMode. With Immediate, the default, the claim is bound or provisioned as soon as it is created, without knowing where its Pod will run. For zonal disks that can strand a Pod: the disk lands in zone a while the Pod's node selector or anti-affinity requires zone c, and the Pod never schedules. WaitForFirstConsumer delays binding and provisioning until a Pod using the claim is scheduled, so the volume follows the Pod's constraints.

What happens after: the reclaim policy. Deleting the claim releases the PV. With Retain, the PV moves to Released: data kept, but not handed to a new claim until an admin cleans it up. With Delete, the PV and its backing disk are removed. Dynamically provisioned PVs inherit the class's reclaimPolicy, which defaults to Delete; Recycle is deprecated.

When to use which

  • Writing an app: write only a PVC, and set storageClassName when you need something other than the default. Never reference a PV by name in a Pod.
  • Running a StatefulSet: write volumeClaimTemplates; the controller creates one PVC per replica, such as data-db-0, and the class provisions each disk. Why that workload and not another is in DaemonSet vs Deployment vs StatefulSet.
  • Running a platform: publish StorageClasses, one per tier, with WaitForFirstConsumer for zonal storage and Retain where losing data by deleting a claim is unacceptable.
  • Pre-existing data: create a static PV for the existing disk and pre-bind it: volumeName on the claim, claimRef on the PV, so no other claim takes it.

Watch it run

The lesson's animation follows a three-replica database StatefulSet. It creates db-0 first with a claim from its volumeClaimTemplate, data-db-0; a StorageClass provisions a PersistentVolume and binds it, one claim to one volume. db-1 and db-2 follow in order, each with its own ReadWriteOnce disk. When db-1's node fails, the claim and volume survive and the replacement db-1 mounts the same claim. Scaling to two keeps data-db-2, and the last step deletes that claim, where the class's Delete policy takes the data with it. This article zooms in on the provisioning and binding step in the middle.

Storage: PV, PVC & StatefulSet

Step 1 of 11

A database's replicas are not interchangeable: each needs its own name and its own data.

The same interactive animation as the lesson — step through it with the controls.

The code

The three objects as each owner writes them. The admin's class waits for the first consumer and retains data; the developer's claim names only the class, size and mode:

# illustrative: the platform team's StorageClass
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: fast }
provisioner: csi.example.com            # the CSI driver that creates disks
parameters: { type: ssd }               # provisioner-specific
reclaimPolicy: Retain                   # default would be Delete
volumeBindingMode: WaitForFirstConsumer # provision in the zone the Pod lands in
allowVolumeExpansion: true
---
# illustrative: the app team's claim; a PV appears once a Pod uses it
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: data, namespace: shop }
spec:
  storageClassName: fast                # omit for the default class; "" for no class
  accessModes: [ReadWriteOnce]
  resources: { requests: { storage: 10Gi } }
# kubectl get pvc data  ->  STATUS Pending until a Pod using it is scheduled

A toy binder, not the real controller. Static PVs are tried first and the smallest fit wins (a choice of the toy); if none fits and the class can provision, a PV of the exact size is created. The 10Gi claim lands on the 20Gi volume, the 500Gi claim gets a provisioned one, and a second claim for the class-less NFS share waits:

import random

PVS = [  # static PVs an admin created; manual PVs default to reclaim Retain
    {"name": "nfs-50", "cls": "", "modes": {"RWX", "RWO"}, "gi": 50, "claim": None},
    {"name": "ssd-20", "cls": "fast", "modes": {"RWO"}, "gi": 20, "claim": None},
    {"name": "ssd-100", "cls": "fast", "modes": {"RWO"}, "gi": 100, "claim": None},
]
CLASSES = {"fast": "Delete"}   # class -> reclaimPolicy of what its provisioner makes

def fits(pv, pvc):
    return (pv["claim"] is None and pv["cls"] == pvc["cls"]
            and pvc["modes"] <= pv["modes"] and pv["gi"] >= pvc["gi"])

def bind(pvc, pvs):
    free = [pv for pv in pvs if fits(pv, pvc)]
    if free:                                   # static: an existing PV, maybe bigger
        pv = min(free, key=lambda v: v["gi"])  # toy choice: the smallest that fits
    elif pvc["cls"] in CLASSES:                # dynamic: the class provisions one
        pv = {"name": "pvc-" + pvc["name"], "cls": pvc["cls"], "modes": set(pvc["modes"]),
              "gi": pvc["gi"], "claim": None, "reclaim": CLASSES[pvc["cls"]]}
        pvs.append(pv)
    else:
        return pvc["name"] + ": Pending (no match, nothing to provision it)"
    pv["claim"] = pvc["name"]
    return f"{pvc['name']} -> {pv['name']} ({pv['gi']}Gi)"

for name, cls, mode, gi in [("data", "fast", "RWO", 10), ("logs", "fast", "RWO", 80),
                            ("big", "fast", "RWO", 500), ("shared", "", "RWX", 10),
                            ("shared2", "", "RWX", 10)]:
    print(bind({"name": name, "cls": cls, "modes": {mode}, "gi": gi}, PVS))
# data -> ssd-20 (20Gi)
# logs -> ssd-100 (100Gi)
# big -> pvc-big (500Gi)
# shared -> nfs-50 (50Gi)
# shared2: Pending (no match, nothing to provision it)

Deleting claims shows the two reclaim policies: the provisioned volume goes with its claim, while the retained NFS share becomes Released and is still not handed to the next claim. The seeded check then runs 500 random clusters and confirms each claim bound exactly when a brute-force reading of the rules says it could, to a volume that obeys them, one claim per volume:

def delete_claim(name, pvs):
    pv = next(p for p in pvs if p["claim"] == name)
    if pv.get("reclaim", "Retain") == "Delete":
        pvs.remove(pv)
        return f"delete {name}: {pv['name']} and its disk are gone"
    pv["phase"] = "Released"        # claim ref kept, so fits() will not hand it out
    return f"delete {name}: {pv['name']} Released, data kept for an admin"

print(delete_claim("big", PVS))       # delete big: pvc-big and its disk are gone
print(delete_claim("shared", PVS))    # delete shared: nfs-50 Released, data kept for an admin
print(bind({"name": "shared3", "cls": "", "modes": {"RWX"}, "gi": 10}, PVS))
# shared3: Pending (no match, nothing to provision it)

def check(seed):  # seeded check: every result agrees with a brute-force reading of the rules
    rng = random.Random(seed)
    pvs = [{"name": f"pv{i}", "cls": rng.choice(["", "fast", "slow"]), "claim": None,
            "modes": set(rng.sample(["RWO", "ROX", "RWX"], rng.randint(1, 3))),
            "gi": rng.choice([5, 10, 50, 100])} for i in range(6)]
    ok = True
    for i in range(8):
        c = {"name": f"c{i}", "cls": rng.choice(["", "fast", "slow"]),
             "modes": {rng.choice(["RWO", "RWX"])}, "gi": rng.choice([1, 10, 60])}
        possible = any(fits(pv, c) for pv in pvs) or c["cls"] in CLASSES
        bound = "Pending" not in bind(c, pvs)
        pv = next((p for p in pvs if p["claim"] == c["name"]), None)
        ok &= possible == bound and (pv is None or (pv["cls"] == c["cls"]
              and c["modes"] <= pv["modes"] and pv["gi"] >= c["gi"]))
    claims = [p["claim"] for p in pvs if p["claim"]]
    return ok and len(claims) == len(set(claims))      # one claim, one volume

print(all(check(seed) for seed in range(500)))   # True

The complexity

  • Binding is a scan of the available PVs per new claim, cheap at any realistic count; the real cost is the provisioner's call to the storage backend, which takes seconds and can fail on quota or zone capacity.
  • Static stock wastes capacity twice: volumes nobody claims, and claims that land on bigger volumes than they asked for. Dynamic provisioning sizes each disk to its claim.
  • Changing a claim later: a bound claim can grow, never shrink, and only if its class sets allowVolumeExpansion: true. Changing a class means a new class and new claims.

Where it goes wrong

  • Claim stuck Pending. No PV fits and the class has no working provisioner, the class name is misspelt, or WaitForFirstConsumer is simply waiting for a Pod. kubectl describe pvc says which.
  • "" versus omitted. storageClassName: "" means "no class, static only"; leaving the field out means "the default class". Copying one for the other either strands the claim or provisions a disk you did not expect.
  • Pod unschedulable after Immediate binding. The disk was created in one zone and the Pod's constraints point elsewhere. Use WaitForFirstConsumer for zonal storage.
  • Data lost by cleanup. Deleting a claim whose PV has Delete deletes the disk. Set Retain on classes for data you cannot recreate, and change it on existing PVs before risky cleanups.
  • Reading access modes as locks. ReadWriteOnce limits mounting to one node, not one Pod; ReadWriteOncePod is the single-Pod mode. Access modes also do not write-protect a mounted volume.

When it shows up in interviews

As the storage half of "design a stateful service on Kubernetes": the interviewer wants to hear who writes which object, why apps use claims, and what WaitForFirstConsumer and Retain protect against. The StatefulSet that generates claims per replica is covered in StatefulSet vs Deployment, and the whole flow is animated in the Storage: PV, PVC and StatefulSet lesson.

How to say it in an interview

"A PV is the actual storage, cluster-scoped, made by an admin or by a provisioner. A PVC is the app's request: size, access mode, class, in its namespace; Pods mount claims. A StorageClass tells a provisioner how to make PVs on demand and sets the reclaim policy and binding mode. The control loop binds one claim to one PV that matches class, modes and at least the size, or provisions one. For zonal disks I use WaitForFirstConsumer so the disk follows the Pod, and for data I cannot lose I use Retain, because the dynamic default is Delete."

Frequently asked questions

What is the difference between a PV and a PVC in Kubernetes?

A PersistentVolume is the storage itself: a cluster-wide object describing one disk or share, its capacity, access modes and reclaim policy. A PersistentVolumeClaim is a namespaced request for storage by size, access mode and class. Kubernetes binds each claim to one matching volume, and Pods mount the claim, never the volume directly.

Do I need a StorageClass to use a PVC?

No. A claim can bind to a statically created PV, and storageClassName: "" asks for exactly that. Without a class, though, nothing is provisioned on demand, so the claim stays Pending until an admin creates a matching volume. Leaving the field out uses the cluster's default class, if one is set.

Why is my PVC stuck in Pending?

Usually one of three reasons: no PV matches its class, access modes and size and nothing can provision one; the class name is wrong; or the class uses WaitForFirstConsumer and no Pod using the claim has been scheduled yet. kubectl describe pvc shows the events that say which.

Sources