Skip to content
Shell & TerminalDeep Dive Published Updated 7 min readViews unavailable

lsof Query Design: Selector Algebra, NUL-Safe Fields, and Snapshot Limits

Build precise lsof queries with explicit AND semantics, numeric selectors, NUL-delimited fields, and safeguards against stale process observations.

lsof is most useful when a command answers a narrowly framed question. Its selection flags can constrain process IDs, file descriptors, network endpoints, and other attributes, but their combination rules are easy to misread. A query that looks like “this PID and these descriptors” can instead mean “this PID or any process with one of these descriptors.” The -a option is the crucial switch for combining different selection groups with logical AND.

This article focuses on query semantics and reliable automation, not on general disk-space troubleshooting. lsof is a platform utility rather than a POSIX shell builtin, and option availability and visibility vary by operating system, build, and privileges. Check the manual installed on the target host before depending on less common selectors or output fields.

Selection groups default to OR

Within one selection set, alternatives are naturally combined: a list of process IDs selects any listed process, and a list of descriptor values selects any listed descriptor. Across distinct selection groups, lsof normally combines selections with OR. Adding -a changes that relationship to AND. The difference is operationally significant:

PID=12345
lsof -nP -a -p "$PID" -d 0,1,2

This requests files for which the process matches the PID selector and the descriptor matches the listed values. Without -a, the PID selection and descriptor selection are alternatives, so the result may contain unrelated processes merely because they have one of those descriptor numbers open. Descriptor 0, 1, and 2 are common in many processes, which makes this mistake particularly noisy.

The same principle applies to network filters, with an important distinction: the pinned lsof manual itself shows -iTCP -sTCP:LISTEN to select TCP listeners without -a. Keep -a when you need to AND independent selectors such as a PID and descriptor list, as in the earlier example. If you add -a to a network query, remember that it ANDs all list-selection options rather than only the options that follow it:

lsof -nP -iTCP:443 -sTCP:LISTEN

Here -n disables host-name conversion and -P disables service-name conversion in displayed endpoints. These flags improve predictable display and reduce resolver lookups; they do not change whether the query is authorized or whether the operating system exposes every socket. If the target host uses a different lsof implementation, check the local manual for supported address syntax and protocol-state behavior.

Keep identifiers as arguments, never as shell code

When a PID comes from a variable, pass it as one quoted argument and validate its shape before invoking the utility. Do not concatenate it into eval, a generated command string, or a shell fragment:

case ${PID-} in
    ''|*[!0-9]*)
        printf '%s\n' 'PID must contain decimal digits' >&2
        exit 2
        ;;
esac

lsof -nP -a -p "$PID" -d 0,1,2

This check rejects empty and non-decimal values. A production tool should also apply its own range policy and handle lsof errors explicitly. Never assume that a syntactically valid PID still identifies the same process later: process identifiers can be reused after an exit.

Use field output for programs, not display columns

The default lsof table is intended for people. File names can contain whitespace, and display formatting is not a stable record protocol. For automation, select fields with -F. For example, the p, c, f, and n fields report process ID, command, descriptor, and name. Ending the field list with 0 requests NUL as the field terminator in the upstream lsof interface:

umask 077
tmp=$(mktemp "${TMPDIR:-/tmp}/lsof-fields.XXXXXX") || exit 1
trap 'rm -f "$tmp"' 0
trap 'exit 1' HUP INT TERM
if ! lsof -nP -Fpcfn0 -a -p "$PID" -d 0,1,2 >"$tmp"; then
    echo "lsof query failed" >&2
    exit 1
fi
# After saving the tokenizer below as parse_lsof.py, feed it the private file.
python3 parse_lsof.py <"$tmp"

mktemp creates a unique temporary path, while umask 077 restricts access to the captured process and path data. Adapt the mktemp template for the target platform if its implementation differs; do not replace it with a predictable filename and a plain > redirection.

Treat that output as bytes. A field is identified by its first byte and followed by its value; NUL terminates the field. The pinned lsof manual also says that it emits a newline after each process and file set in NUL mode, so a consumer must handle those set delimiters in addition to NUL field terminators. Do not pipe this format through line-oriented tools and assume each newline is a record boundary. POSIX filenames may contain newline bytes, while NUL and slash cannot occur inside a filename. However, do not assume an lsof n field is the pathname’s raw bytes: the manual specifies field framing, not a version-independent filename-escaping contract. In a disposable macOS test with lsof 4.91, an embedded newline was represented as the two bytes \n; verify the exact encoding on the target build before attempting to round-trip a name. A minimal Python tokenizer can preserve lsof field payload bytes and skip the leading set-delimiter newline:

import sys

raw = sys.stdin.buffer.read()
for token in raw.split(bytes([0])):
    token = token.lstrip(b"\n")  # remove set delimiters; preserve encoded field payload bytes.
    if not token:
        continue
    field, value = token[:1], token[1:]
    # Keep bytes until the consumer has applied its decoding policy.
    print(field, repr(value))

This loop demonstrates byte-safe tokenization; it is not a full lsof record parser. A consumer that needs rows should retain the current process ID and command fields, start a file record at each descriptor field, attach following name fields to that record, and handle absent or repeated fields. Preserve the encoded name-field bytes and follow the target lsof build’s documented or tested escaping rules before treating them as filesystem pathnames; do not assume decoding alone reconstructs the original pathname. Also distinguish lsof’s field framing from a guarantee that every version emits every selected field for every object.

Avoid treating a displayed path as a durable object identity. A file can be renamed or replaced after enumeration. If a later operation must act on the same file, use a safer application-specific handle workflow and revalidate the object rather than reopening an unchecked path from stale output.

A listing is an observation, not a reservation

lsof reports open-file information visible to the process at the time it inspects the system. That observation does not reserve a descriptor, prevent a close, pin a PID against reuse, or provide an atomic transaction with a later command. This is an operational inference from an inspection tool’s model, not a promise that every platform gathers all kernel data at precisely one instant. The manual’s repeat mode can emit repeated observations, which is useful for monitoring but still does not turn a listing into a lock.

Do not implement “find a free port, then bind it” by querying lsof and assuming the port will remain free. Bind the socket and handle the kernel’s success or address-in-use error. Similarly, do not kill a process based only on a stale PID from a saved lsof report. Recheck process identity and use a service manager or application protocol where available.

Permissions matter. Some platforms restrict visibility into other users’ processes, containers, protected processes, or kernel resources. A missing row can mean no matching object, insufficient visibility, or a platform-specific limitation. Report the command version, host, selectors, and execution identity alongside audit evidence so another operator can reproduce the result.

Test query semantics with bounded fixtures

Before using selectors in an incident script, run them against a process you own and whose descriptors are known. Compare the AND query with the same command without -a, but summarize the result rather than printing unrelated process names into a shared log. Check that the intended PID remains the only selected process when descriptor 0, 1, and 2 are included. Separately test a network query on a disposable local listener and verify its address formatting with -nP.

Test machine output with names containing spaces and newlines in a disposable directory, and verify both that the parser splits fields on NUL rather than line breaks and how the target lsof version encodes the newline in the name field. Do not assume the encoded field is a byte-for-byte pathname. Include empty output, permission errors, process exit during collection, and a process that closes a descriptor while the command runs. Those cases reveal assumptions that a clean interactive table will not.

The compact review checklist is: identify every selection group; use -a when the intended relationship is conjunction; quote and validate selectors; use -nP where numeric output is preferable; request only fields the parser understands; frame data with NUL and parse bytes; and treat results as time-bounded observations. These steps make lsof automation much less likely to confuse a broad match with a precise one.

Related:

Sources:

Comments