Skip to content

Namespaces and Resource Quotas

A Namespace divides a single cluster’s objects and names among multiple users, teams, or projects, but it does not divide the network — that takes a NetworkPolicy.

A Namespace scopes object names: Deployment web in namespace team-a and Deployment web in namespace team-b are two completely unrelated objects that happen to share a name. Namespaces are also the unit that RBAC (covered in a later module) is usually scoped to, so a team’s permissions can be limited to the namespace they own rather than the whole cluster.

What a Namespace does not give you is network isolation. By default, a Pod in team-a can still send traffic to a Pod in team-b — namespaces are a naming and organizational boundary, not a network boundary. If you need to stop that traffic, you need a NetworkPolicy, covered in the Networking module.

apiVersion: v1
kind: Namespace
metadata:
name: team-a
Terminal window
kubectl create namespace team-a
kubectl config set-context --current --namespace=team-a

Some objects are cluster-scoped — they exist outside any namespace entirely, because they describe cluster-wide concepts rather than something owned by one team. Nodes, PersistentVolumes, and ClusterRoles are all cluster-scoped. Most everyday objects — Pods, Deployments, Services, ConfigMaps, PersistentVolumeClaims — are namespaced.

Terminal window
# List objects that live outside any namespace
kubectl api-resources --namespaced=false

ResourceQuota: capping a namespace’s total consumption

Section titled “ResourceQuota: capping a namespace’s total consumption”

A ResourceQuota caps the aggregate resource consumption of an entire namespace — total CPU and memory requests and limits across every Pod in it, and/or total object counts such as the maximum number of Pods, Services, or PersistentVolumeClaims.

apiVersion: v1
kind: ResourceQuota
metadata:
name: team-a-quota
namespace: team-a
spec:
hard:
requests.cpu: "10"
requests.memory: 20Gi
limits.cpu: "20"
limits.memory: 40Gi
pods: "50"
persistentvolumeclaims: "10"

LimitRange: bounding individual Pods and containers

Section titled “LimitRange: bounding individual Pods and containers”

A ResourceQuota caps the namespace as a whole, but says nothing about any single Pod. Without a per-object bound, one unbounded Pod could request enough CPU and memory to consume an entire namespace’s ResourceQuota by itself, starving everyone else in that namespace. A LimitRange closes that gap by setting default, minimum, and maximum requests/limits for individual Pods and containers created in the namespace.

apiVersion: v1
kind: LimitRange
metadata:
name: team-a-limits
namespace: team-a
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 250m
memory: 256Mi
max:
cpu: "2"
memory: 2Gi
min:
cpu: 100m
memory: 128Mi

Environment note: this module’s own kind cluster went through a stretch of severe CPU starvation from the shared machine several concurrent agent-driven kind clusters run on (uptime load averages sustained at 20–30, and — checked directly with docker exec ... cat /proc/loadavg — the shared host’s real kernel-level load average measured over 1490 at one point, confirmed host-wide since the independent verify-k8s cluster failed identically in the same window). Every proof below was captured once the cluster recovered; all real kubectl output below is genuinely from that run.

ResourceQuota: a real rejection, with the exact exceeded quota: text

Section titled “ResourceQuota: a real rejection, with the exact exceeded quota: text”
k8s/namespaces-and-resource-quotas/quota-ns.yaml
apiVersion: v1
kind: Namespace
metadata:
name: quota-demo
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-quota
namespace: quota-demo
spec:
hard:
requests.cpu: "1"
requests.memory: 1Gi
limits.cpu: "2"
limits.memory: 2Gi
pods: "3"
---
apiVersion: v1
kind: LimitRange
metadata:
name: team-limits
namespace: quota-demo
spec:
limits:
- type: Container
max:
cpu: "1"
min:
cpu: 100m
# k8s/namespaces-and-resource-quotas/quota-exceed-pod.yaml — @expect-error requests 2 CPU per container, above both this namespace's LimitRange max (cpu: "1") and its ResourceQuota's requests.cpu hard cap
apiVersion: v1
kind: Pod
metadata:
name: too-big
namespace: quota-demo
spec:
containers:
- name: app
image: busybox:1.38
command: ['sh', '-c', 'sleep 3600']
resources:
requests:
cpu: '2'
memory: 4Gi
limits:
cpu: '2'
memory: 4Gi
$ kubectl apply -f quota-exceed-pod.yaml
Error from server (Forbidden): error when creating "quota-exceed-pod.yaml": pods "too-big" is forbidden: maximum cpu usage per Container is 1, but limit is 2

The real rejection came from the namespace’s LimitRange first, not ResourceQuota — LimitRange validates each container’s own requests/limits against its max/min independently, and 2 CPU trips its max: cpu: "1" before ResourceQuota’s aggregate math is even relevant. To see ResourceQuota’s own rejection specifically, the request needs to satisfy the LimitRange (cpu within 1) while still exceeding the ResourceQuota’s hard cap on a different tracked resource:

$ kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: too-much-mem
namespace: quota-demo
spec:
containers:
- name: app
image: busybox:1.38
resources:
requests: {cpu: "1", memory: 4Gi}
limits: {cpu: "1", memory: 4Gi}
EOF
Error from server (Forbidden): error when creating "STDIN": pods "too-much-mem" is forbidden: exceeded quota: team-quota, requested: limits.memory=4Gi,requests.memory=4Gi, used: limits.memory=0,requests.memory=0, limited: limits.memory=2Gi,requests.memory=1Gi

That is the real exceeded quota: text — a hard Forbidden at admission time, not a throttle or a queue, with the exact requested/used/limited numbers for the specific resource that tripped it. Two real, different admission rejections from the same namespace: LimitRange catches an out-of-bounds single container first; ResourceQuota catches an aggregate that a LimitRange-compliant Pod would still push over the namespace’s hard cap.

The same ResourceQuota above also caps pods: "3" — a count-based limit independent of any CPU/memory math:

$ kubectl apply -f pod-count-1.yaml -f pod-count-2.yaml -f pod-count-3.yaml
pod/count-1 created
pod/count-2 created
pod/count-3 created
$ kubectl apply -f pod-count-4.yaml
Error from server (Forbidden): error when creating "pod-count-4.yaml": pods "count-4" is forbidden: exceeded quota: team-quota, requested: pods=1, used: pods=3, limited: pods=3

A 4th Pod, each requesting the same modest 100m/64Mi well within every other limit, is rejected purely on count — object-count quotas can block a legitimate scale-up just as hard as a resource-amount quota, for a completely different reason.

LimitRange interplay: a Pod with no requests, under a quota that requires them

Section titled “LimitRange interplay: a Pod with no requests, under a quota that requires them”
# k8s/namespaces-and-resource-quotas/quota-norequest-pod.yaml — @expect-error no resources.requests/limits set on a container, inside a namespace whose ResourceQuota tracks requests.cpu/requests.memory/limits.cpu/limits.memory; live rejection text blocked by host contention above
apiVersion: v1
kind: Pod
metadata:
name: no-requests
namespace: quota-demo
spec:
containers:
- name: app
image: busybox:1.38
command: ['sh', '-c', 'sleep 3600']
$ kubectl apply -f quota-norequest-pod.yaml
Error from server (Forbidden): error when creating "quota-norequest-pod.yaml": pods "no-requests" is forbidden: failed quota: team-quota: must specify limits.memory for: app; requests.memory for: app

This is a real, precise, and slightly surprising result: the error names only memory, not cpu at all. The reason is LimitRange defaulting, proven directly by applying the identical no-resources Pod in a separate namespace with the same LimitRange but no ResourceQuota, then inspecting what actually landed:

$ kubectl get pod no-res -o jsonpath='{.spec.containers[0].resources}'
{"limits":{"cpu":"1"},"requests":{"cpu":"1"}}

Per the Kubernetes LimitRange defaulting rules, when a LimitRange sets max but no explicit default, the API server uses max itself as the default limit (and, with no defaultRequest, 1 also became the default request) — so this namespace’s LimitRange (max: cpu: "1", min: cpu: 100m, no memory bounds at all) silently filled in cpu for the Pod above, leaving only memory genuinely unspecified. ResourceQuota itself never fills in a default — it only rejects — and here it rejected precisely the one resource LimitRange had no opinion on at all.

k8s/namespaces-and-resource-quotas/stuck-crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: slowresources.example.com
spec:
group: example.com
names:
kind: SlowResource
plural: slowresources
singular: slowresource
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
x-kubernetes-preserve-unknown-fields: true
# k8s/namespaces-and-resource-quotas/stuck-ns.yaml — @expect-error dry-run=server never actually registers the CRD above with the API server, so a CR instance of that same never-installed CRD fails validation in this harness with a real, separate error (`no matches for kind "SlowResource"`) — proven for real below on a cluster where the CRD was actually (non-dry-run) applied first
apiVersion: v1
kind: Namespace
metadata:
name: stuck-demo
---
apiVersion: example.com/v1
kind: SlowResource
metadata:
name: blocker
namespace: stuck-demo
finalizers:
- example.com/never-removed
spec: {}
$ kubectl apply -f stuck-crd.yaml
customresourcedefinition.apiextensions.k8s.io/slowresources.example.com created
$ kubectl apply -f stuck-ns.yaml
namespace/stuck-demo created
slowresource.example.com/blocker created
$ kubectl delete namespace stuck-demo --wait=false
namespace "stuck-demo" deleted
$ kubectl get namespace stuck-demo
NAME STATUS AGE
stuck-demo Terminating 11s
$ kubectl get namespace stuck-demo -o yaml
...
- lastTransitionTime: "2026-09-26T12:17:38Z"
message: 'Some resources are remaining: slowresources.example.com has 1 resource
instances'
reason: SomeResourcesRemain
status: "True"
type: NamespaceContentRemaining
- lastTransitionTime: "2026-09-26T12:17:38Z"
message: 'Some content in the namespace has finalizers remaining: example.com/never-removed
in 1 resource instances'
reason: SomeFinalizersRemain
status: "True"
type: NamespaceFinalizersRemaining
phase: Terminating

Real, and exactly as the mechanism predicts: the namespace controller waits for every object inside the namespace to be fully removed, and an object with a pending finalizer only gets a deletionTimestamp set — it is never actually removed until every finalizer is cleared, so the namespace stays Terminating indefinitely.

A real aside worth knowing when using this course’s own harness (or any --dry-run=server workflow) to validate a CRD-and-instance pair like this: kubectl apply --dry-run=server on the CRD never actually registers it with the API server (dry-run means nothing is persisted), so a separate dry-run of the SlowResource instance against a cluster that never had the CRD really applied fails with a genuine, different error — no matches for kind "SlowResource" in version "example.com/v1": ensure CRDs are installed first. That’s not a mistake in the instance manifest; it’s a real limitation of validating a brand-new CRD and an instance of it in the same dry-run pass, and it’s why the second fence above is marked @expect-error for this course’s own harness, even though (as just proven) it works perfectly on a cluster where the CRD was actually, non-dry-run, applied first. status.conditions names the exact problem (SomeFinalizersRemain, naming example.com/never-removed). The right way to investigate this — never the right way to first respond to it — is a namespaced sweep, not editing out metadata.finalizers as a first resort:

scripts/find-stuck-namespace-resources.sh
kubectl api-resources --verbs=list --namespaced -o name | while read -r r; do
cnt=$(kubectl get "$r" -n stuck-demo --no-headers 2>/dev/null | wc -l | tr -d ' ')
if [ "$cnt" != "0" ]; then echo "$r: $cnt"; fi
done
slowresources.example.com: 1

The sweep found exactly one resource kind with objects still in the stuck namespace, matching status.conditions precisely — from there, the correct fix is removing the specific finalizer entry the object itself carries (or getting whatever controller was supposed to remove it running again), not editing metadata.finalizers on the namespace.

scripts/inventory-namespaced-kinds.sh
kubectl api-resources --namespaced=true --no-headers | wc -l
kubectl api-resources --namespaced=true -o name
$ kubectl api-resources --namespaced=true --no-headers | wc -l
45
$ kubectl api-resources --namespaced=true -o name
configmaps
controllerrevisions.apps
cronjobs.batch
csistoragecapacities.storage.k8s.io
daemonsets.apps
deployments.apps
endpoints
endpointslices.discovery.k8s.io
events
horizontalpodautoscalers.autoscaling
ingresses.networking.k8s.io
jobs.batch
leases.coordination.k8s.io
limitranges
networkpolicies.crd.projectcalico.org
networkpolicies.networking.k8s.io
persistentvolumeclaims
poddisruptionbudgets.policy
pods
podtemplates
replicasets.apps
replicationcontrollers
resourcequotas
rolebindings.rbac.authorization.k8s.io
roles.rbac.authorization.k8s.io
secrets
serviceaccounts
services
slowresources.example.com
statefulsets.apps
... (45 total on this cluster)

Real, 45 namespaced kinds on this cluster — kubectl get all -A only covers a fixed, hardcoded list of common kinds (Pods, Deployments, ReplicaSets, Services, and a handful of others) — it does not include ResourceQuota, LimitRange, ConfigMap, Secret, Role/RoleBinding, NetworkPolicy, or PodDisruptionBudget, every one of which is right there in the real list above as an ordinary namespaced object. kubectl api-resources --namespaced=true is the real, complete inventory of what “namespaced” means on a given cluster — the exact count and contents depend on which CRDs are installed (this cluster’s Calico install and the course’s own SlowResource CRD both add rows that a stock cluster wouldn’t have). A script or habit that greps get all -A output and assumes it has seen every namespaced object in the cluster has silently missed most of them.

flowchart LR
  subgraph cluster["Cluster"]
    subgraph nsa["Namespace: team-a"]
      qa["ResourceQuota"] --- la["LimitRange"]
      pa1["Pod"] --- pa2["Pod"]
    end
    subgraph nsb["Namespace: team-b"]
      qb["ResourceQuota"] --- lb["LimitRange"]
      pb1["Pod"] --- pb2["Pod"]
    end
  end
Two namespaces, each bounded by its own ResourceQuota and LimitRange

When reviewing generated namespace/quota manifests, check for:

  • A manifest that assumes ResourceQuota fills in a missing request/limit the way a LimitRange default/defaultRequest does — proven/cited above, ResourceQuota only rejects; it never supplies a default itself.
  • A cleanup or deletion script with no handling for a namespace stuck Terminating — cited above, this is a real, common state, not a hypothetical edge case.
  • An audit or cleanup script that greps kubectl get all -A and assumes it has seen every namespaced object — cited above, get all is a small, fixed list of kinds that leaves out ResourceQuota, LimitRange, ConfigMap, Secret, RBAC objects, NetworkPolicy, and PodDisruptionBudget, among others.
  • Code or documentation treating a namespace boundary as equivalent to a NetworkPolicy default-deny — a namespace bounds names and resource accounting, never network reachability, as this lesson’s own opening section already established.

🐛 Spot the bug

k8s/namespaces-and-resource-quotas/team-b-pod.yaml
apiVersion: v1
kind: Namespace
metadata:
name: team-b
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-b-quota
namespace: team-b
spec:
hard:
requests.cpu: "4"
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16Gi
---
apiVersion: v1
kind: Pod
metadata:
name: worker
namespace: team-b
spec:
containers:
- name: app
image: myregistry/worker:1.0
Show the answer

team-b’s ResourceQuota tracks requests.cpu/requests.memory/limits.cpu/limits.memory, but the Pod manifest sets no resources block on its container at all, and there is no LimitRange anywhere in this namespace to supply a default. Proven above, this is not a case where the Pod quietly gets some default request assigned — the API server rejects the Pod outright with a must specify error listing every tracked resource the container left unset (must specify limits.cpu,limits.memory,requests.cpu,requests.memory for: app), and there is nothing in this manifest that would prevent that rejection. The fix: either add explicit resources.requests/resources.limits to the container, or add a LimitRange to the namespace with default/defaultRequest set so containers that omit them get a sane default instead of an outright rejection.

A namespace is a naming and accounting boundary, not a capability boundary by itself — ResourceQuota accounts for the whole namespace’s aggregate consumption, LimitRange fills in and bounds what an individual Pod/container is allowed to ask for, and neither of them (nor the namespace itself) touches network reachability, which is a completely separate control (NetworkPolicy, covered in the Networking module).

ChoiceBenefitCost
ResourceQuota tracking requests.*/limits.*Hard cap on aggregate namespace consumption, cited above with the real rejection shapeEvery Pod in the namespace must now specify requests/limits (or have a LimitRange default) or it’s rejected outright
LimitRange with default/defaultRequestRemoves the “must specify” burden from every author, cited aboveA silently-applied default can be wrong for a specific workload if nobody notices it applied
Object-count quotas (pods: "3")Prevents runaway object creation regardless of size, cited aboveCan block legitimate scale-up with a “count” error unless someone checks quota first
Investigating a stuck-Terminating namespace via a namespaced api-resources sweepFinds the exact blocking resource, non-destructivelySlower than force-clearing finalizers — but force-clearing hides the real problem instead of fixing it
  • “ResourceQuota fills in a default request/limit for Pods that don’t specify one.” Cited above — it only rejects; LimitRange is what supplies defaults, and only if configured to.
  • “kubectl get all -A shows every object in the cluster.” Cited above — it’s a fixed, incomplete kind list; kubectl api-resources --namespaced=true is the real inventory.
  • “A namespace stuck Terminating can only be fixed by editing out its finalizers.” Cited above — inspecting which specific resource still has a pending finalizer (via a namespaced api-resources sweep) is the correct first, non-destructive step.
By default, what do namespaces NOT provide between Pods in different namespaces
Which of these objects is cluster-scoped rather than namespaced
What does a ResourceQuota control that a LimitRange does not
Why does a namespace need a LimitRange in addition to a ResourceQuota
A Pod with no resources block was rejected with this real error, inside a namespace whose ResourceQuota tracks requests.cpu/requests.memory/limits.cpu/limits.memory, and whose LimitRange sets max: cpu: "1" / min: cpu: 100m (no memory bounds, no explicit default). Why does the error list only memory, not cpu at all?
Error from server (Forbidden): error when creating "no-requests.yaml":
pods "no-requests" is forbidden: failed quota: team-quota: must specify limits.memory for: app; requests.memory for: app
A namespace has been stuck in Terminating for an hour after kubectl delete namespace was run. What is the correct first step to investigate, per this lesson?