udev Rules and Predictable Device Naming

A udev rule is a single line of text that tells the userspace device manager (systemd-udevd, the daemon descended from the original udev project) what to do when the kernel reports that a device appeared, changed, or vanished. Each rule is a comma-separated list of match keys (conditions on the event device — its kernel name, subsystem, sysfs attributes, properties) followed by assignment keys (actions — set the node owner/group/mode, create a /dev symlink, run a program, tag the device, or rename a network interface). The most consequential thing this rule engine does today is impose Predictable Network Interface Names: instead of the unstable eth0/eth1 the kernel hands out in probe order, udev derives a stable name like enp0s31f6 or eno1 from the device’s firmware index or PCI geography, so the same NIC gets the same name on every boot (udev(7); PREDICTABLE_INTERFACE_NAMES.md). This note owns the rule syntax and the naming scheme; the daemon’s event-loop, netlink listener, and worker model are covered in udev and Device Management.

Mental Model

Think of udev as a rule-matching machine wired to a uevent stream. The kernel device model (see The Linux Device Model) emits a uevent netlink message every time a struct device is added, bound, changed, or removed (see Uevents and the Kernel-Userspace Netlink Channel). For each event, systemd-udevd spawns a worker that walks every rule file in lexical order, evaluating the match keys of each rule against that one device. When all match keys of a rule succeed, the rule’s assignment keys fire. The cumulative effect of all matching rules — symlinks created, permissions set, properties added, the network name chosen — is then applied to the device.

flowchart TB
  K["Kernel adds struct device<br/>(e.g. a PCI NIC)"] -->|"uevent over netlink"| D["systemd-udevd worker"]
  D --> R["Walk ALL .rules files<br/>in lexical order"]
  subgraph RULES["Rule evaluation (per device)"]
    direction TB
    M1["Match keys:<br/>ACTION==, SUBSYSTEM==,<br/>KERNEL==, ATTR{}==, ENV{}=="]
    M2{"all match<br/>keys true?"}
    A1["Assignment keys:<br/>NAME=, SYMLINK+=,<br/>OWNER/GROUP/MODE=,<br/>ENV{}=, RUN+=, TAG+="]
    M1 --> M2 -->|yes| A1
    M2 -->|no| SKIP["skip rule"]
  end
  R --> RULES
  A1 --> APPLY["Apply: create symlinks,<br/>set node perms,<br/>set udev properties"]
  RULES -.->|"for NICs:<br/>IMPORT{builtin}=net_id<br/>sets ID_NET_NAME_*"| NETID["net_id builtin"]
  NETID -->|"net_setup_link applies<br/>NamePolicy from .link"| RENAME["rename eth0 -> enp0s31f6"]

The udev rule engine as a per-device matcher. What it shows: one uevent drives one worker, which evaluates every rule’s match keys against that device and fires the assignment keys of those that match; for network interfaces a special path (net_id builtin → net_setup_link) derives and applies the predictable name. The insight to take: udev does not “configure devices” imperatively — it reacts to kernel events by pattern-matching, so a device’s final name and permissions are the accumulated result of all matching rules, with /etc rules able to override /usr/lib ones because they sort with higher priority for same-named files.

Mechanical Walk-through — Rule Files and Ordering

Rules live in .rules files spread across four directories, and the critical fact is that they are collectively sorted and processed in lexicographic order, regardless of the directory in which they live (udev(7)). The four locations are /usr/lib/udev/rules.d (distribution-shipped rules), /usr/local/lib/udev/rules.d, /run/udev/rules.d (volatile, runtime-generated), and /etc/udev/rules.d (local administrator rules). Sorting is by filename, not by path — so 10-foo.rules in /usr/lib runs before 99-bar.rules in /etc, which is why convention prefixes rule files with a two-digit number to control ordering. When two files share the same name in different directories, only one is used and priority resolves the conflict: per the man page, “Files in /etc/ have the highest priority, files in /run/ take precedence over files with the same name under /usr/” (udev(7)). The standard override idiom is to copy /usr/lib/udev/rules.d/NN-name.rules to /etc/udev/rules.d/NN-name.rules and edit it, or to mask it entirely by symlinking the /etc copy to /dev/null. Files without the .rules suffix are ignored.

Lexical-across-directories ordering matters because rules are not independent: a += append, a GOTO jump, or a := final-assignment in an early file changes what a later file sees. The numeric prefix is therefore load-bearing, not cosmetic.

The Two Halves of a Rule — Match Keys vs Assignment Keys

Every rule is a comma-separated list of KEY operator value triples. The operator disambiguates whether a triple is a match (a condition) or an assignment (an action) (udev(7)):

OperatorMeaning (verbatim from udev(7))
==”Compare for equality.” (key has the specified value)
!=”Compare for inequality.” (key lacks the value, or is absent)
=”Assign a value to a key.”
+=”Add the value to a key that holds a list of entries.”
-=”Remove the value from a key that holds a list of entries.” (since v217)
:=”Assign a value to a key finally; disallow any later changes.” (since v247)

The := final-assignment is the escape hatch for “set this and let no later rule, including an admin’s, override it.” += and -= only make sense for list-valued keys such as SYMLINK, TAG, and RUN.

Match keys test the event device. The most common are ACTION (the event verb: add, remove, change, bind, unbind, move), KERNEL (the device’s kernel name, e.g. sda, eth0), SUBSYSTEM (block, net, tty, usb, …), DRIVER (the bound driver), and ATTR{filename} (a sysfs attribute of the event device itself, e.g. ATTR{idVendor}=="1d6b"). A second family searches upward through the device’s parents in the sysfs tree: KERNELS, SUBSYSTEMS, DRIVERS, and ATTRS{filename} match if any ancestor satisfies them — indispensable when the interesting attribute lives on a parent (the USB hub, the PCI bridge) rather than on the leaf. ENV{key} matches a udev property; TAG/TAGS match tags previously attached by earlier rules; CONST{key} matches system constants (arch, virt, cvm, since v244); TEST{octal mode} tests for a file’s existence/permissions. Two programmatic keys execute external logic: PROGRAM runs a program and matches if it exits zero, and RESULT matches against the output of the most recent PROGRAM (udev(7)).

Assignment keys act on a matched device. NAME sets a network interface name (more on its narrow scope below); SYMLINK adds /dev symlinks (SYMLINK+="disk/by-id/..."); OWNER, GROUP, and MODE set the device node’s ownership and permission bits; TAG attaches a tag; ENV{key} sets a property (a leading . in the key makes it non-persistent, not stored in the udev database); ATTR{key} writes a value back into a sysfs attribute; RUN{program|builtin} queues a program or builtin to run after processing; OPTIONS sets per-rule flags (e.g. OPTIONS+="string_escape=replace" sanitizes characters in NAME/SYMLINK/ENV, OPTIONS+="watch" enables inotify watching); and LABEL/GOTO implement intra-file jumps. IMPORT{type} pulls variables into the device’s property set from a program, a builtin, a file, the udev database, the kernel command line, or a parent device (udev(7)).

Uncertain

Verify: the exact since vNNN version each operator/key was introduced (e.g. -= v217, := v247, CONST{} v244, SYSCTL{} match v240 / assign v220). Reason: these version numbers come from the upstream udev(7) man page rendered at man7.org, which tracks current systemd, not a frozen release; the vault pins kernel facts to 6.12/6.18 LTS but udev ships with systemd, whose version is distro-dependent. To resolve: check the systemd version on the target distro and the corresponding udev(7). uncertain

A worked rule set

# /etc/udev/rules.d/99-local.rules

# 1. Give a USB serial adapter a stable symlink by its serial number.
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idSerial}=="A6008isP", \
  SYMLINK+="ttyFTDI", GROUP="dialout", MODE="0660"

# 2. Set permissions on an FPGA programmer regardless of its enumeration order.
SUBSYSTEM=="usb", ATTR{idVendor}=="09fb", ATTR{idProduct}=="6010", \
  MODE="0666", TAG+="uaccess"

# 3. Run a script when a specific block device is added (then jump past the rest).
ACTION=="add", SUBSYSTEM=="block", KERNEL=="sd?1", \
  ENV{ID_FS_UUID}=="1234-5678", RUN+="/usr/local/bin/mount-backup.sh", GOTO="end"

LABEL="end"

Line by line: rule 1 matches a tty-subsystem device whose USB parent (ATTRS{} searches upward) has Future Technology Devices International (FTDI) vendor 0403 and a specific serial; it appends a symlink /dev/ttyFTDI, sets the group to dialout, and sets mode 0660. Rule 2 matches the raw usb device by ATTR{} (note: ATTR on the event device, ATTRS for ancestors), opens permissions, and adds the uaccess tag that systemd-logind uses to grant the seat’s active user access. Rule 3 fires only on add, only on partitions named sd?1, only when udev’s blkid-derived ID_FS_UUID property matches; it queues a script and GOTOs the trailing LABEL to skip later rules. The RUN program must be short-lived — udev kills long runners; daemonizing work belongs in a systemd service triggered by a SYSTEMD_WANTS tag, not in RUN.

Why NAME rarely creates /dev nodes anymore

A historical gotcha: people expect NAME="foo" to rename a /dev node. It does not. The man page is explicit: “The name of a device node cannot be changed by udev, only additional symlinks can be created” (udev(7)). The kernel’s devtmpfs creates and names all device nodes at the moment the device is registered, before udev runs; udev then only adjusts ownership/permissions and adds SYMLINKs. So NAME today is meaningful almost exclusively for the net subsystem, where it sets the network interface name — which is precisely the mechanism the predictable-naming scheme uses.

Predictable Network Interface Names — The Why

Before predictable naming, the kernel named Ethernet interfaces eth0, eth1, … in the order their drivers finished probing. On modern hardware that order is nondeterministic: driver initialization is parallelized and depends on PCI scan timing, module load order, and interrupt arrival, so, in the upstream maintainers’ words, “as soon as multiple network interfaces are available the assignment of the names eth0, eth1 and so on is generally not fixed anymore and it might very well happen that eth0 on one boot ends up being eth1 on the next” (PREDICTABLE_INTERFACE_NAMES.md). Worse, there was a genuine race: “the userspace components trying to assign the interface name raced against the kernel assigning new names from the same ethX namespace, a race condition with all kinds of weird effects, among them that assignment of names sometimes failed.” A firewall rule or ifcfg file pinned to eth0 could silently apply to the wrong NIC after a reboot — a security and reliability hazard. The fix: derive names from something stable about the hardware’s location, and use a separate namespace (en*, wl*, …) so userspace renaming never collides with kernel ethX assignment.

How net_id Derives the Name

The actual derivation is done by the net_id udev builtin, which inspects the device’s position in the firmware and bus topology and exports a set of ID_NET_NAME_* properties (systemd.net-naming-scheme(7)). Every name begins with a two-character type prefix: en for Ethernet, wl for wireless LAN (WLAN), ww for wireless WAN (WWAN), ib for InfiniBand, sl for serial-line IP, mc for the Management Component Transport Protocol. After the prefix comes a suffix encoding where the device is, and net_id computes up to four candidate names:

  • ID_NET_NAME_ONBOARD = prefix + onumber — the firmware-provided on-board index. On PCI this comes from the ACPI _DSM “acpi_index” / the device’s firmware_node (e.g. an Intel onboard NIC reporting index 1 yields eno1). Example: eno1.
  • ID_NET_NAME_SLOT = prefix + [Pdomain] + sslot + [ffunction] + [ddev] — the firmware-provided PCI hot-plug slot number (the _SUN slot from ACPI, read via the firmware_node/sun sysfs file). Example: ens1.
  • ID_NET_NAME_PATH = prefix + [Pdomain] + pbus + sslot + [ffunction] + [ddev] — the geographical PCI path: the bus-device-function (BDF) location on the PCI hierarchy. Example: enp2s0, or with a non-zero function enp0s31f6 (PCI domain 0, bus 0, slot/device 0x1f, function 6 — a typical Intel PCH-integrated NIC). See PCI and PCIe Enumeration for what bus/slot/function mean.
  • ID_NET_NAME_MAC = prefix + x + the twelve hexadecimal digits of the MAC address. Example: enx78e7d1ea46da. This is stable across reboots but ugly, so it is available but not chosen by default.

The Pdomain component is prepended only when the PCI domain is non-zero, which is why most desktop names omit it. The whole point is that each of these encodes a fixed property of the slot or path, not a probe-order counter — re-seat the same card in the same slot and you get the same name.

Uncertain

Verify: the precise sysfs sources net_id reads for the onboard index vs the slot number in 6.12-era systemd, and that firmware_node/sun is the slot source (this came from the v257 changelog note “PCI slot number is now read from firmware_node/sun sysfs file”). Reason: the exact attribute paths shifted across systemd versions and the reading of the upstream source file was not completed at the pinned tag. To resolve: read src/udev/net/naming-scheme.c/net_id’s implementation at the systemd version shipped on the target distro. uncertain

How a Candidate Becomes the Applied Name

Computing ID_NET_NAME_* does not rename anything by itself. Two more steps, both driven by rule files, do the work. The shipped rule /usr/lib/udev/rules.d/80-net-setup-link.rules runs IMPORT{builtin}="net_id" (populating the ID_NET_NAME_* properties on the device) and then invokes the net_setup_link builtin (80-net-setup-link.rules). net_setup_link consults the matching .link file (a systemd.link(5) configuration, by default /usr/lib/systemd/network/99-default.link) and applies its NamePolicy= — an ordered list of policies, the first applicable one winning. The default is NamePolicy=keep kernel database onboard slot path (systemd.link(5)):

  • keep — if userspace already named the device (e.g. inside a container or a netns), leave it.
  • kernel — if the kernel marked its name as predictable (NET_NAME_PREDICTABLE), keep that.
  • database — use a name from udev’s hardware database (ID_NET_NAME_FROM_DATABASE), for hardware with known-good vendor naming.
  • onboardID_NET_NAME_ONBOARD (the eno* names).
  • slotID_NET_NAME_SLOT (the ens* names).
  • pathID_NET_NAME_PATH (the enp*s* names).
  • macID_NET_NAME_MAC (the enx* names) — not in the default list, opt-in only.

So a NIC with usable firmware onboard info becomes eno1; one with only a hot-plug slot becomes ens1; one with neither — but a known PCI path — becomes enp0s31f6; and if every policy fails, udev leaves the kernel’s eth0 (RHEL “Implementing consistent network interface naming”). This precedence is exactly firmware-told-me → I-figured-it-out-from-geography → give-up, which is why onboard names beat path names: the firmware’s own numbering is friendlier and more stable than a raw BDF.

Naming schemes are versioned

A subtle accuracy trap: the algorithm itself is versioned. systemd freezes each iteration of the naming logic as a named naming scheme (v238, v239, … up through the current release), and the active scheme can change a device’s name across a systemd upgrade (systemd.net-naming-scheme(7)). Notable changes: v240 added the ib InfiniBand prefix and began using the ACPI index even when it is 0; v247 added PCI-bridge slot-naming conflict detection; v249 raised the onboard index limit to 65535; v251 relaxed multi-function PCI slot naming; v255 made SR-IOV representor naming default. You can pin a scheme with net.naming-scheme=v247 on the kernel command line to keep names stable across an OS upgrade — a real production concern when a distro bumps systemd and a server’s NIC silently renames.

Disabling Predictable Naming

There are three documented ways to turn it off, each operating at a different layer (PREDICTABLE_INTERFACE_NAMES.md):

  1. Kernel command line net.ifnames=0 — the blunt instrument. The kernel-side hint disables the predictable scheme globally; interfaces revert to eth0/wlan0. Add biosdevname=0 too on systems with the Dell biosdevname package, which is a competing scheme.
  2. Mask the default .link policy: ln -s /dev/null /etc/systemd/network/99-default.link. With no policy to apply, net_setup_link does not rename, so the kernel name stands.
  3. Provide your own .link files to name interfaces by an explicit policy or a fixed Name= keyed on MAC/path — the right approach when you want stable names of your choosing rather than the defaults.

Method 1 brings back the original instability problem and is generally a mistake on multi-NIC machines; method 3 is the supported way to get custom stable names.

Failure Modes and Common Misunderstandings

“My udev rule doesn’t fire.” The usual cause is matching ATTR{} (event device) when the attribute lives on a parent, where ATTRS{} is required — or vice versa. Diagnose with udevadm info -a -p $(udevadm info -q path -n /dev/sdX), which prints the device and every ancestor with the exact KERNEL/SUBSYSTEM/ATTR{} strings you can match. Use udevadm test /sys/class/net/enp0s31f6 to replay rule processing for one device and see which rules matched.

“I edited a rule but nothing changed.” udev caches rules; run udevadm control --reload to re-read them, then udevadm trigger to re-emit synthetic uevents for existing devices. Rules in /usr/lib that you edited directly will be clobbered on the next package update — copy to /etc instead.

“My NIC renamed itself after an OS upgrade and the network broke.” A systemd version bump moved the active naming scheme; a NIC that was enp3s0 is now enp3s0f0 or similar. This is the single most common predictable-naming production incident. Pin net.naming-scheme=vNNN to the version you validated against, and/or write an explicit .link file keyed on the MAC address so the name is yours, not the scheme’s.

NAME= doesn’t create my /dev node.” As above — NAME only renames network interfaces; for /dev nodes use SYMLINK. devtmpfs already created the node.

Two rules fight over the same symlink. SYMLINK+= from multiple rules all apply; if two devices claim the same symlink, OPTIONS+="link_priority=N" resolves which device’s symlink wins.

Alternatives and When to Choose Them

  • eudev — Gentoo’s fork of udev, kept independent of systemd; same rule syntax, same net_id-style naming, for systems that reject systemd. Rule files are largely portable.
  • mdev (BusyBox) and mdevd — minimal device managers for embedded/initramfs use. mdev uses a single /etc/mdev.conf with a far simpler match syntax (no upward ATTRS{} search, no builtins); it does not implement predictable network naming. Choose it when udev’s footprint is too large and devices are few and known.
  • biosdevname — Dell’s older alternative naming scheme (em1, p1p1). Predates and competes with systemd’s; disabled via biosdevname=0. Largely superseded by the systemd scheme.
  • Manual .link files — not an alternative engine but the supported customization point within udev: rather than fighting the default policy, write /etc/systemd/network/10-mynic.link matching [Match] MACAddress=... and [Link] Name=wan0. This is the production answer to “I want my own stable names.”

Production Notes

Cloud images almost universally ship with predictable naming disabled (net.ifnames=0) because the hypervisor presents a single virtio NIC whose eth0 is stable enough and because cloud-init/Ansible playbooks historically assume eth0. On bare-metal servers and anything multi-NIC, predictable naming is the default and the right choice — but operators must treat the naming scheme as a versioned dependency: validate the name after any systemd upgrade, and pin net.naming-scheme= in fleets that cannot tolerate a rename. The uaccess tag plus systemd-logind is the modern replacement for the old “add the user to the plugdev group” pattern for granting desktop users access to hot-plugged hardware — a single TAG+="uaccess" is cleaner than a MODE="0666" that opens the device to everyone.

See Also