You need one stable answer from the service manager: is this unit active, what is its main PID, which links does networkd own, did NTP lock? The usual shortcut is to scrape systemctl show or systemctl status text. That works until a column moves, a locale changes the wording, or a script runs against a container whose systemctl is a different generation.
busctl talks to the same D-Bus APIs those tools already use. You get typed method calls, property reads, object trees, live monitors, and JSON-friendly output without inventing a parser for human-oriented CLI text.
This guide is operational. Commands and behavior come from busctl(1), the D-Bus type system, and live read-only labs against systemd 257 on Debian 13. Mutating examples (StartUnit, set-property) are quoted from the man page so you can run them deliberately on a lab host.
What busctl is (and is not)
| Piece | Job |
|---|---|
| D-Bus | Binary IPC message bus: system bus + per-user session buses |
busctl |
Operator CLI to list peers, introspect objects, call methods, read/write properties, monitor traffic |
systemctl / hostnamectl / … |
Friendly wrappers over a subset of the same APIs |
varlinkctl |
Parallel stack for Varlink JSON services (resolved, many newer helpers) |
dbus-send / gdbus |
Older/general D-Bus clients; busctl is the systemd-native ergonomic tool |
busctl is not a replacement for every shell one-liner. Use it when you want:
- stable machine-readable state (especially with
-j/--json=), - APIs that have no polished CLI yet,
- debugging of who owns a name and what objects they export,
- the same call path your production client will use.
You do not need to abandon varlinkctl where a service already speaks Varlink. D-Bus remains the primary control plane for org.freedesktop.systemd1, login1, hostname1, timedate1, network1, and many third-party daemons.
Prerequisites
command -v busctl
busctl --version
# busctl itself: systemd 209+
# wait + --limit-messages=: 257+
# --capsule=: 256+
# --json= broadly available on modern systemd; behavior verified on 257
# Default target is the system bus:
busctl list --acquired | head
Most read-only calls against systemd, hostname1, timedate1, and resolve1 work as a normal user. Start/stop/enable and some host mutations need root or a polkit rule.
Mental model: service → object → interface → member
Every call needs four coordinates (properties drop the method name and use a property name instead):
SERVICE well-known or unique bus name e.g. org.freedesktop.systemd1
OBJECT object path e.g. /org/freedesktop/systemd1
INTERFACE interface name e.g. org.freedesktop.systemd1.Manager
MEMBER method, property, or signal e.g. GetDefaultTarget / Version / UnitNew
D-Bus also has unique names like :1.4 (connection IDs) and well-known names like org.freedesktop.systemd1 (stable aliases services claim).
Lab 1 — Inventory the bus
# Peers that currently hold well-known names
busctl list --acquired
# Only unique connection IDs
busctl list --unique
# Names that can be activated on demand but are not running yet
busctl list --activatable
# Who owns the bus / a name / a PID
busctl status
busctl status org.freedesktop.resolve1
busctl status 1
On a typical host you will see names such as:
-
org.freedesktop.systemd1— PID 1 service manager -
org.freedesktop.login1— logind -
org.freedesktop.hostname1/timedate1/locale1 -
org.freedesktop.network1/resolve1when networkd/resolved are enabled - third-party names (
Avahi, desktop portals, containers, …)
status prints process credentials (UID/GID, cgroup, unit, command line). That is the fastest way to answer “which unit owns this bus name?” without grepping ps.
User bus and containers
# Calling user's session bus
busctl --user list --acquired
# Another user's user bus on the host (needs privileges)
busctl --user --machine=debian@.host list --acquired
# System bus inside a local machine/container registered with machined
# busctl --machine=myvm list --acquired
--machine= and --host= mirror the rest of the systemd tooling family. Capsules (256+) use --capsule=.
Lab 2 — Tree and introspect
# Object tree for the service manager
busctl tree org.freedesktop.systemd1 | head -40
# Flat path list
busctl tree --list org.freedesktop.systemd1 | head
# Manager object members
busctl introspect org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager | head -60
# Raw Introspectable XML (useful for codegen / deep dumps)
busctl introspect --xml-interface org.freedesktop.systemd1 \
/org/freedesktop/systemd1 | head
Unit object paths are escaped. Dots become _2e, dashes _2d, and so on:
systemd-journald.service → /org/freedesktop/systemd1/unit/systemd_2djournald_2eservice
cron.service → /org/freedesktop/systemd1/unit/cron_2eservice
Do not hand-build those paths in scripts. Call GetUnit (below) and use the returned object path.
Lab 3 — Properties without text scraping
# Manager identity
busctl get-property org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager \
Version Architecture Virtualization
# Terse vs verbose vs JSON
busctl get-property org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager Environment
busctl get-property --verbose org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager Environment
busctl get-property -j org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager Version
# → {"type":"s","data":"257.13-1~deb13u1"}
Other high-signal hosts:
# hostname1
busctl get-property org.freedesktop.hostname1 \
/org/freedesktop/hostname1 \
org.freedesktop.hostname1 \
Hostname KernelName KernelRelease OperatingSystemPrettyName Chassis
# timedate1
busctl get-property org.freedesktop.timedate1 \
/org/freedesktop/timedate1 \
org.freedesktop.timedate1 \
Timezone NTP NTPSynchronized
Example outputs from a live Debian 13 KVM guest:
s "openclaw-host1"
s "Linux"
s "6.12.111+deb13-amd64"
s "Debian GNU/Linux 13 (trixie)"
s "vm"
s "Etc/UTC"
b true
b true
Terse mode prefixes every value with its D-Bus type code (s string, b boolean, u uint32, as array of strings, …). That is intentional: scripts can branch on type without guessing.
Resolve a unit, then read its state
UNIT_PATH=$(busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager GetUnit s systemd-journald.service \
| awk -F'"' '{print $2}')
busctl get-property org.freedesktop.systemd1 "$UNIT_PATH" \
org.freedesktop.systemd1.Unit \
Id Description LoadState ActiveState SubState
busctl get-property org.freedesktop.systemd1 "$UNIT_PATH" \
org.freedesktop.systemd1.Service MainPID
Typical result:
s "systemd-journald.service"
s "Journal Service"
s "loaded"
s "active"
s "running"
u 331
That is the stable equivalent of grepping systemctl status for “Active:” and “Main PID:”.
Lab 4 — Method calls and signature syntax
call takes:
busctl call SERVICE OBJECT INTERFACE METHOD [SIGNATURE [ARGS...]]
The signature string is the D-Bus type signature for the arguments you pass, not for the return value. Common codes:
| Code | Meaning | busctl argument shape |
|---|---|---|
s |
string | one string |
b |
boolean |
true/false/yes/no/1/0
|
y n q i u x t
|
integers | decimal string |
d |
double | float string |
o |
object path | /org/... |
as |
array of strings | count, then N strings: as 2 a b
|
a{sv} |
dict of string→variant | count, then key type value… |
(…) |
struct | fields inline |
v |
variant | inner signature + value |
Examples from busctl(1):
# string
s jawoll
# string array with three entries
as 3 hello world foobar
# dict string → variant
a{sv} 3 One s Eins Two u 2 Yes b true
Safe read-only manager calls
# Default target
busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager GetDefaultTarget
# s "graphical.target" # or multi-user.target, etc.
# Unit file state (enabled/disabled/static/…)
busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager GetUnitFileState s systemd-journald.service
# s "static"
# List matching unit files (patterns arrays: states, then names)
busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager ListUnitFilesByPatterns \
asas 0 1 'ssh*.service'
# Failed units only (empty array if healthy)
busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager ListUnitsFiltered as 1 failed
# networkd links
busctl call org.freedesktop.network1 \
/org/freedesktop/network1 \
org.freedesktop.network1.Manager ListLinks
# logind sessions
busctl call org.freedesktop.login1 \
/org/freedesktop/login1 \
org.freedesktop.login1.Manager ListSessions
# Who owns a well-known name right now?
busctl call org.freedesktop.DBus \
/org/freedesktop/DBus \
org.freedesktop.DBus GetNameOwner s org.freedesktop.systemd1
# s ":1.4"
Controlled mutation (lab only)
From the official man-page examples — run only where you intend to change state:
# Start a unit (job mode "replace"); returns a job object path
sudo busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager StartUnit \
ss "cups.service" "replace"
# o "/org/freedesktop/systemd1/job/42684"
# Temporary manager log level (restores on daemon-reexec/reboot policy dependent)
sudo busctl set-property org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager LogLevel s debug
busctl get-property org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager LogLevel
# s "debug"
# Put it back
sudo busctl set-property org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager LogLevel s info
Useful call flags:
| Flag | Effect |
|---|---|
-q / --quiet
|
Suppress reply payload; exit code still reports success/failure |
--expect-reply=no |
Fire-and-forget (no exit status from the method result) |
--auto-start=no |
Do not activate a stopped service just to talk to it |
--allow-interactive-authorization=no |
Fail instead of prompting polkit/agent |
--timeout=5s |
Cap wait (default 25s for call) |
-j / --json=
|
Structured output for replies/properties |
Lab 5 — Monitor, capture, wait
# Everything (noisy — use on a quiet lab)
sudo busctl monitor
# Only traffic involving the service manager
sudo busctl monitor org.freedesktop.systemd1
# Match rules (see sd_bus_add_match(3))
sudo busctl monitor --match="type='signal',interface='org.freedesktop.systemd1.Manager'"
# Exit after N messages (257+)
sudo busctl monitor -N 5 org.freedesktop.systemd1
# pcapng for Wireshark
sudo busctl capture org.freedesktop.systemd1 > /tmp/bus.pcapng
Waiting for a single signal (257+):
# Block until the manager emits a signal (example shape)
busctl wait /org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager UnitNew
wait can omit the service name to accept the signal from any sender. Combine with --timeout= and -N when you want bounded automation instead of an open-ended trap.
emit (242+) synthesizes a signal — useful for testing clients, rarely needed in day-2 ops:
busctl emit /com/example/Obj com.example.Iface Ping
# optional: --destination=org.example.Service
Lab 6 — Small automation patterns
JSON health probe
#!/usr/bin/env bash
set -euo pipefail
ver=$(busctl get-property -j org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager Version | jq -r .data)
ntp=$(busctl get-property -j org.freedesktop.timedate1 \
/org/freedesktop/timedate1 \
org.freedesktop.timedate1 NTPSynchronized | jq -r .data)
failed=$(busctl call -j org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager ListUnitsFiltered as 1 failed)
echo "systemd=$ver ntp_synced=$ntp"
echo "$failed" | jq .
-j embeds D-Bus type metadata ({"type":"s","data":"..."}) so conversions stay lossless. Pipe through jq only after you decide which fields matter.
Map PID → unit without fragile cgroup string cuts
PID=${1:?usage: $0 <pid>}
PATH_OBJ=$(busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager GetUnitByPID u "$PID" \
| awk -F'"' '{print $2}')
busctl get-property org.freedesktop.systemd1 "$PATH_OBJ" \
org.freedesktop.systemd1.Unit Id ActiveState
Prefer GetUnit over hand-escaped paths
name=systemd-networkd.service
path=$(busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager GetUnit s "$name" \
| awk -F'"' '{print $2}')
printf '%s → %s\n' "$name" "$path"
busctl vs varlinkctl vs systemctl
| Need | Reach for |
|---|---|
| Human operator UX, unit lifecycle day-to-day |
systemctl, journalctl, loginctl, … |
| Stable D-Bus method/property access, scripts, unfamiliar services | busctl |
JSON Varlink sockets (io.systemd.*, custom tools) |
varlinkctl |
| One-off debug of who is on the bus | busctl list/status/monitor |
| Wireshark-level bus forensics |
busctl capture → pcapng |
Many components expose both stacks over time. Resolved is a good example: CLI/resolvectl for humans, Varlink for JSON-native automation, and classic D-Bus still present on a lot of hosts. Pick the API the service documents as primary; use busctl introspect / varlinkctl introspect when docs lag.
Operational caveats
-
Policy still applies. A successful
introspectdoes not mean every method is callable. polkit and D-Bus policy may require root or an interactive authorization agent (--allow-interactive-authorization=). -
Activation side effects. Default
--auto-start=yescan start a service just because you talked to its name. Use--auto-start=noin careful probes. -
Escaped paths. Never concatenate unit names into object paths by hand for production scripts — call
GetUnit/LoadUnit. -
Output stability. Terse type-prefixed text and
--json=are the machine interfaces. Do not scrapebusctl treeart orstatushuman columns. -
Noise and privilege on monitor. Full-bus
monitoris root-heavy and high volume; always filter with a service name,--match=, or-N. -
Version gates.
waitand--limit-messages=need systemd 257+. Confirm withbusctl --versionbefore baking them into fleet scripts. -
User vs system bus. Forgetting
--useris the most common “Name has no owner” footgun for session services.
Hands-on mini lab (read-only)
# 1) Who is on the system bus?
busctl list --acquired
# 2) Confirm manager version over D-Bus (not shell scraping)
busctl get-property -j org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager Version
# 3) Default target + journald state
busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager GetDefaultTarget
JP=$(busctl call org.freedesktop.systemd1 \
/org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager GetUnit s systemd-journald.service \
| awk -F'"' '{print $2}')
busctl get-property org.freedesktop.systemd1 "$JP" \
org.freedesktop.systemd1.Unit ActiveState SubState
busctl get-property org.freedesktop.systemd1 "$JP" \
org.freedesktop.systemd1.Service MainPID
# 4) Host + time snapshot
busctl get-property org.freedesktop.hostname1 \
/org/freedesktop/hostname1 org.freedesktop.hostname1 \
Hostname OperatingSystemPrettyName Chassis
busctl get-property org.freedesktop.timedate1 \
/org/freedesktop/timedate1 org.freedesktop.timedate1 \
Timezone NTPSynchronized
# 5) Optional: one filtered monitor sample (Ctrl+C or -N 3)
# sudo busctl monitor -N 3 org.freedesktop.systemd1
If those five blocks work, you can replace a surprising amount of brittle systemctl | awk glue with explicit D-Bus calls.
References
-
busctl(1)— commands, signature formatting, examples (StartUnit,LogLevel, verbose arrays) - D-Bus Specification — Type system (basic types, arrays, variants, dicts)
-
sd_bus_add_match(3)— match rule language used bybusctl monitor --match= -
sd-bus(3)— C API behind the same stack -
org.freedesktop.systemd1Manager interface — discovered live viabusctl introspect(methods such asGetUnit,ListUnitsFiltered,StartUnit,GetUnitByPID) - Companion tooling:
varlinkctl(1),systemctl(1),machinectl(1),wireshark(1)for pcapng captures
D-Bus is not going away just because Varlink exists. busctl is the difference between hoping a text UI stays parseable and calling the same interface your language bindings will use in production — with types, object paths, and exit codes that mean something.
Top comments (0)