Why read the API, not the table
At 3am somebody asks "which node is the broken pod on, and how many times has it restarted?". kubectl get pods prints a table, you squint at it, you copy the wrong line. Worse: a runbook (a written step-by-step procedure, 0.21) that says "look at the output" cannot be run by a script.
Every question like that has an exact answer stored in the object. This lesson teaches you to ask kubectl for just that field, printed exactly the way you want - one line, a file, your own table - so you never read a table by eye again when it matters.
What you need to know already: JSON and jq (7.11, 7.13), pipes and xargs (1.7, 7.16), quoting in bash (6.6), pods and their status (15.14), Deployments and ReplicaSets (15.16), labels and -l selectors, annotations and namespaces (15.26), the speed kit k alias (15.3).
Every object is one document
The tables kubectl prints are a view: a few columns picked from a much bigger record. The record itself - every field any component wrote - is what -o yaml (print as YAML) or -o json (print as JSON) shows. They are the same data in two notations.
$ k create deployment web --image=nginx:1.27 --replicas=2 # AlreadyExists is fine
deployment.apps/web created
$ k get $(k get pods -l app=web -o name | head -1) -o yaml
apiVersion: v1
kind: Pod
metadata:
creationTimestamp: "2026-09-23T10:31:07Z"
labels:
app: web
pod-template-hash: vf2s9shm74
name: web-5jth9
ownerReferences:
- apiVersion: apps/v1
kind: ReplicaSet
name: web-vf2s9shm74
...
spec:
containers:
- image: nginx:1.27
name: nginx
nodeName: worker-2
...
status:
containerStatuses:
- name: nginx
ready: true
restartCount: 0
state:
running:
startedAt: "2026-09-23T10:31:10Z"
phase: Running
podIP: 10.244.2.118
The second command nests one kubectl inside another: $(...) (command substitution, 2.11) runs k get pods -l app=web -o name | head -1 first, which prints one pod as pod/<name>, and that becomes the argument of the outer k get ... -o yaml.
Read the document from the top. It always has the same three parts:
metadata- identity:name,namespace,labels, annotations,creationTimestamp(when it was created, in UTC), andownerReferences: which object owns this one (here the ReplicaSetweb-vf2s9shm74, 15.16).spec- what was asked for: the containers and their image, andnodeName, which the scheduler filled in (15.11).status- what the system observed:phase, the pod IP, and one entry per container incontainerStatuseswithready,restartCountand its currentstate.
The rule of thumb: you write spec, controllers write status. "What image did we ask for" is a spec question; "is it running, how often did it restart" is a status question.
Every question you will be asked is a path into that document: node = spec.nodeName, restarts = status.containerStatuses[0].restartCount. Look at an object once with -o yaml to find the path, then extract just that.
jsonpath: ask for one field
-o jsonpath='TEMPLATE' prints only what the template picks. The template is text with expressions in curly braces {...}; each expression is a path that starts with . (the object itself):
k get pod web-5jth9 -o jsonpath='{.spec.nodeName}'
worker-2
k get pod web-5jth9 -o jsonpath='{.status.containerStatuses[0].restartCount}'
0
.spec.nodeName- go intospec, takenodeName.containerStatuses[0]-containerStatusesis a list (the-lines in YAML);[0]takes the first entry. Lists count from 0, like jq (7.11).- Always wrap the template in single quotes: it contains
{,[,"and*, which bash would otherwise try to interpret (6.6).
A list has one more level. Without a name, get pods returns one document of kind List whose items field holds the objects: {kind: List, items: [pod, pod, ...]}. So every path on a list starts with .items:
$ k get pods -l app=web -o jsonpath='{.items[*].metadata.name}'
web-vf2s9shm74-5jth9 web-vf2s9shm74-fp4q6
$ k get pods -l app=web -o jsonpath='{.items[0].status.podIP}'
10.244.2.118
[*] means "every entry"; kubectl prints the results on one line, separated by spaces. [0] is just the first pod. The most common jsonpath mistake is forgetting .items on a list (you get empty output) or adding it on a single object (also empty).
range: one line per item
For one line per pod, loop with {range LIST}...{end}. Everything between range and end is printed once per entry, and paths inside it start from that entry:
$ k get pods -l app=web -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.phase}{"\t"}{.spec.nodeName}{"\n"}{end}'
web-vf2s9shm74-5jth9 Running worker-2
web-vf2s9shm74-fp4q6 Running worker-1
{"\t"} and {"\n"} are literal strings inside the template: a tab and a newline. The output is tab-separated (TSV), ready for cut, sort or awk. Without a final {"\n"} there is no newline at the end and your next prompt ends up glued to the output.
Filters: pick list entries by a condition
[?(CONDITION)] keeps only the list entries where the condition is true. Inside it, @ means "the entry being tested":
$ k get nodes -o jsonpath='{.items[*].status.conditions[?(@.type=="Ready")].status}'
True True True
$ k get pods -o jsonpath='{.items[?(@.status.phase=="Running")].metadata.name}'
Read the first one slowly: for every node (.items[*]), go into its list of conditions (15.7: Ready, MemoryPressure, DiskPressure...), keep the one whose type is "Ready", print its status. Three True = three healthy nodes. It is the standard way to script "are all nodes ready". The second prints the names of the pods whose phase is Running.
Keys that contain dots
jsonpath uses . to separate path steps. But many label and annotation keys contain dots, like deployment.kubernetes.io/revision (the revision number a Deployment keeps, 15.16). Each literal dot in such a key must be escaped with a backslash, \.:
k get deploy web -o jsonpath='{.metadata.annotations.deployment\.kubernetes\.io/revision}'
The mistake to make once: without single quotes, bash removes the backslashes (6.6) before kubectl sees them, kubectl then reads deployment, kubernetes, io/revision as three steps, finds nothing and prints nothing - no error.
Recursive descent: find a key anywhere
.. searches every depth for a key, like .. in jq (7.11):
$ k get pods -l app=web -o jsonpath='{..image}'
nginx:1.27 docker.io/library/nginx:1.27 nginx:1.27 docker.io/library/nginx:1.27
It found every image key - so twice per pod: the one you asked for in spec.containers, and the full name the container runtime actually pulled, in status.containerStatuses (nginx:1.27 is short for docker.io/library/nginx:1.27, 10.5). Handy for exploring; for a precise answer say where: {.items[*].spec.containers[*].image}.
custom-columns: your own table
-o custom-columns=HEADER:PATH,HEADER:PATH,... builds a table with the columns you name. Same paths, without the braces, and each path is applied to every item (so no .items):
$ k get pods -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeName,IP:.status.podIP,RESTARTS:.status.containerStatuses[0].restartCount
NAME NODE IP RESTARTS
web-vf2s9shm74-5jth9 worker-2 10.244.2.118 0
web-vf2s9shm74-fp4q6 worker-1 10.244.1.155 0
NAME:.metadata.name- headerNAME, value.metadata.name.- A field that does not exist on an object prints
<none>. --no-headersdrops the header line, for scripts and files.
Sorting and filtering
k get pods --sort-by=.metadata.creationTimestamp # oldest first
k get pods --sort-by='.status.containerStatuses[0].restartCount'
k get events --sort-by=.lastTimestamp
--sort-by=PATH sorts the items by one field (ascending: smallest, oldest first). Quote it when the path has [ or ]. Sorting events by .lastTimestamp (when the event last happened) gives you a timeline, newest at the bottom.
Field selectors filter by a field value, like -l filters by label:
k get pods --field-selector=status.phase!=Running -A # everything not Running
k get pods -A --field-selector=spec.nodeName=worker-1 # what runs on a node
k get events --field-selector=type=Warning
FIELD=VALUEorFIELD!=VALUE; several joined with commas all have to match.-A(--all-namespaces) searches every namespace; the output gains aNAMESPACEcolumn.- The filtering happens on the API server, so only matching objects come back - fast even on a big cluster.
- Only a few fields per type are supported (
metadata.name,metadata.namespace,spec.nodeName,status.phase, eventtypeandreason...). Anything else fails with "field label not supported". Labels, by contrast, work on every type.
-o name, for pipelines
$ k get pods -l app=web -o name
pod/web-vf2s9shm74-5jth9
pod/web-vf2s9shm74-fp4q6
$ k get pods -l app=web -o name | head -1 | xargs -I{} kubectl logs {}
-o name prints only type/name, one per line. Every kubectl verb accepts type/name, so the output feeds straight back into kubectl: here xargs -I{} (7.16) runs kubectl logs with the first pod in place of {}.
jq, when jsonpath runs out
jsonpath can only select fields: it cannot count, add, group or compare. -o json | jq can, with everything from Ch 7:
k get pods -A -o json | jq -r '.items[] | select(.status.phase!="Running") | "\(.metadata.namespace)/\(.metadata.name)"'
k get pods -o json | jq '[.items[].status.containerStatuses[0].restartCount] | add'
k get pods -A -o json | jq -r '.items[].spec.containers[].image' | sort | uniq -c | sort -rn
- Every pod not Running, as
namespace/name(-r= raw strings, no quotes). - The total number of restarts across the namespace (
addsums the list). - Every image in the cluster, counted and sorted by how often it is used - a quick image inventory (the
sort | uniq -c | sort -rnpattern from 7.4).
Rule of thumb: jsonpath when you need a field (it is short to type and needs nothing installed); jq when you need to compute.
Later (Ch 19): in the Kubernetes admin exam many tasks say "write the result to a file" - this lesson is exactly that skill.
What you can now do:
- find any field's path with
-o yaml, and say whether it is spec or status - extract it with jsonpath: single objects vs
.items,[0],[*],range,[?(@...)]filters, escaped dotted keys,.. - build your own table with custom-columns, sort with
--sort-by, filter on the server with--field-selector - feed
-o nameinto other commands, and switch to-o json | jqto count or group