One solo topic today as it’s a banger of one, and may change your opinion of the default shell on some systems, or your opinion of Zsh in general.
This is an ongoing knowledge transfer from the homelab migration (which is taking much longer than I wanted it to).
zsh Modules
If you’re a macOS users, Zsh is the default shell, but many folks use it on linux/BSD/etc. in place of Bash as it zsh has some fancy/robust features that make it a compelling alternative.
Zsh itself is a tiny core plus a set of loadable shared objects, and most of what folks think of as “zsh features” (i.e., the completion system, the line editor, zstyle) are modules that a stock .zshrc happens to load for you. If you poke a bit deeper into Zsh, you will find it has some unexpected superpowers. Today, we do some of that poking.
zmodload is the whole interface. With no arguments it lists what is currently loaded. Here’s what you can do with it:
zmodload # loaded moduleszmodload -L # dump as zmodload commands, paste-able into .zshrczmodload -e zsh/system # test: is it loaded? (exit status)zmodload -i zsh/datetime # idempotent loadzmodload -u zsh/zprof # unload
Modules are found along $module_path, and many distributions link some modules statically into the binary; zmodload still succeeds on those, but zmodload -u may not work as expected in those circumstances.
A given module exports named features, prefixed by kind: b: builtin, p: parameter, c: condition (for [[ ]]), f: math function. For example:
zmodload -lF zsh/stat # list features, + enabled, - disabledzmodload -F zsh/stat b:zstat # load ONLY the zstat builtin
Several modules export names that collide with legit commands. zsh/stat provides a builtin literally called stat, and zsh/files provides rm, mv, chmod, mkdir, and friends. Load those wholesale and you have sniped and shadowed the system utilities for the rest of the session. The -F form is the correct way in those circumstances.
Autoloading defers the cost until first use:
zmodload -F -a zsh/stat b:zstat # zstat loads the module on first call
Said “cost” is time. These are items in the filesystem that have to be loaded into memory and initialized. The more you have (especially in your startup environment) the longer it’ll take to get to the next step in a script.
Model Inventory
Below is a breakdown of modules I’ve found useful enough to use more than once. I’ve tried to put a “so what” with most of them to encourage their use.
(FWIW, I can’t believe I had no idea these existed prior to a few weeks ago.)
zsh/datetime gives you strftime, $EPOCHSECONDS, and $EPOCHREALTIME. This removes a fork+exec of date from every loop iteration, which is the single biggest speedup in most shell scripts that touch time.
zmodload zsh/datetimestrftime -s ts '%Y-%m-%dT%H:%M:%SZ' $EPOCHSECONDSlocal t0=$EPOCHREALTIME; ...; print $(( EPOCHREALTIME - t0 ))
zsh/parameter exposes the interpreter’s own state as parameters: $commands, $functions, $builtins, $aliases, $options, $parameters, $modules, $funcstack, $funcfiletrace. The idiomatic existence check comes from here, and it costs nothing:
(( $+commands[rg] )) || return 1print -l ${(k)functions} # every defined functionprint $funcfiletrace[1] # caller's file:line, for logging
zsh/system covers the syscall surface: sysread, syswrite, sysopen, zsystem flock, and $sysparams (pid, ppid). flock is the real reason to load it, because correct locking in a shell script is otherwise just absolutely miserable.
zmodload zsh/systemzsystem flock -t 5 /var/lock/mything || { print -u2 "busy"; exit 1 }
zsh/stat as zstat ends the GNU-versus-BSD stat argument permanently. It fills an associative array, so you get named fields with no format string archaeology.
zmodload -F zsh/stat b:zstatzstat -H st -- fileprint $st[size] $st[mtime]
zsh/pcre plus setopt re_match_pcre upgrades the =~ operator from POSIX ERE to real PCRE — yes, real PCRE — with captures landing in $MATCH and $match. Without it, =~ uses zsh/regex and POSIX semantics.
zsh/mathfunc adds the C math library inside $(( )): sqrt, log, sin, rand48, erf, and so on. Arithmetic in zsh is already floating point; this makes it usable.
zsh/zprof is how you fix a slow shell. Load it as the first line of .zshrc, call zprof at the end, and you get a flat profile of every function by self time. Nearly every “my shell takes a gazillion milliseconds to start” problem resolves in one session with this.
zsh/net/tcp and zsh/zselect give you ztcp (raw TCP connections bound to a file descriptor) and select(2) with a timeout. Enough to write a banner grabber or a health check without netcat, though not enough to want to write a scanner in shell. Malware, human, and agentic adversaries also love to use this to avoid detection. You can use this feature to grab remote files without local XDR agents seeing curls all over the place. We may cover this more in depth in a future Drop.
zsh/zpty allocates a pty and drives a program through it. The completion system uses it to interrogate commands that refuse to behave when stdout is a pipe. It is also the best answer to “how do I script something that insists on a terminal.”
zsh/zutil provides zstyle, zparseopts, and zformat. zparseopts is the one to remember: it is a real getopt for zsh functions, long options included.
zsh/files exports builtin mkdir, rm, ln, mv, chmod. Load them under their zf_ names (zmodload -F zsh/files b:zf_mkdir) and you have file operations that work when /usr/bin is unmounted or $PATH is destroyed. This is a great nugget to remember if you’re ever in a rescue-shell, which I think more of us are going to be now that we’re never going to be able to afford new systems thanks to our “AI” overlords.
zsh/mapfile binds $mapfile[path] to whole-file contents for read and write. Convenient and dangerous in equal measure; an accidental assignment truncates a file.
How About An Example?
Fine.
How would you like to have a persistent hash that lives outside of memory and doesn’t require sqlite (et al.)?
zsh/db/gdbm ties an associative array to a GDBM file on disk. Assignment writes through. This gives you a key/value store with no daemon, no client, and no serialization step.
zmodload zsh/db/gdbmztie -d db:gdbm -f ~/.cache/seen.gdbm seen# dedupe across invocations, across daysfor ip in $ips; do (( $+seen[$ip] )) && continue seen[$ip]=$EPOCHSECONDS print -r -- $ipdonezuntie seen
This is super useful for maintaining run-state in cron jobs: last-seen timestamps, scan cursors, “have I already alerted on this?”, etc. One caveat is that values are strings only, and the file is a single-writer format, so pair it with zsystem flock if two jobs might overlap.
We mentioned the network capabilities earlier, so let’s put them in action.
zsh/net/tcp plus zsh/zselect gives you connect-with-timeout and non-blocking reads. A banner grabber in ten lines:
zmodload zsh/net/tcp zsh/zselect zsh/datetimebanner() { local host=$1 port=$2 fd ztcp $host $port 2>/dev/null || { print -r "$host:$port closed"; return 1 } fd=$REPLY if zselect -t 300 -r $fd; then # 3.00s, -t is hundredths read -r -u $fd line print -r "$host:$port $line" else print -r "$host:$port open, silent" fi ztcp -c $fd}
ztcp -l 8080 listens and ztcp -a $fd accepts, so you can stand up a throwaway listener to confirm egress paths or catch a callback without installing anything. Do not build a scanner on this. Do use it when you are on a stripped box with no nc, no curl, and a question that needs answering.
zsh/net/socket is the same idea for Unix domain sockets, which is how you poke at a Docker socket or an agent socket directly.
In this modern world where agents are building stuff that you will end up using whether you know it or like it, there are now more times where some programs require being under a TTY.
zsh/zpty allocates a pty and runs a command inside it. Anything that checks isatty() and changes behavior is now scriptable.
zmodload zsh/zptyzpty -b sess some-interactive-toolzpty -w sess "status"zpty -r sess outputzpty -d sess
You’ll find this useful in “the tool buffers differently when piped” situations and to automating things that refuse to accept stdin from a file. The completion system itself uses it internally for exactly this reason.
macOS and other operating systems that are unix-like have filesystems that support extra properties on filesystem objects.
zsh/attr gives zgetattr, zsetattr, zlistattr, zdelattr. On macOS that means reading com.apple.quarantine and com.apple.metadata:kMDItemWhereFroms directly; on Linux it covers security.* and user.*. Provenance questions answered in-shell:
zmodload zsh/attrzlistattr ~/Downloads/thing.dmg attrsprint -l $attrszgetattr ~/Downloads/thing.dmg com.apple.quarantine q && print -r $q
You can even get all fancy and wield the power of curses! (This kind of curse, not the one that turns folks into newts.)
zsh/curses exposes real curses: windows, colors, input, borders.
zmodload zsh/curseszcurses initzcurses addwin main $LINES $COLUMNS 0 0zcurses bg main white/bluezcurses string main "press q"zcurses refresh mainwhile zcurses input main key; do [[ $key == q ]] && break; donezcurses end
I will absolutely judge you if you decide to write a full dashboard in Zsh with it, but it’s def good for a one-off selector or a live tail with a status line, where pulling in a TUI dependency is not worth it.
We mentioned command options earlier, and this is how that goes.
zparseopts from zsh/zutil handles long options, repeated flags, and option arguments. Your shell functions stop being positional-argument guessing games.
zmodload zsh/zutilmyfn() { local -A opts zparseopts -D -F -A opts -- v -verbose o: -out: || return 1 local out=${opts[--out]:-${opts[-o]:-/dev/stdout}} (( $+opts[-v] + $+opts[--verbose] )) && print -u2 "writing to $out"}
-F makes unknown options an error instead of silently falling through, which is the flag most people omit and then regret.
You can even look deep within the shell itself (shell searching vs soul searching?).
zsh/parameter turns your entire environment into queryable data. Find every function shadowing a real command, or every alias that will bite you in a script:
zmodload zsh/parameterfor f in ${(k)functions}; do (( $+commands[$f] )) && print -r "shadow: $f"; doneprint -l ${(k)aliases}print -r $options[globsubst]
Combine with $funcfiletrace and you can build a logger that reports the exact file and line of the caller, which makes shell libraries debuggable.
And, finally, about that “rescue shell”.
zmodload -F zsh/files b:zf_mkdir b:zf_rm b:zf_mv b:zf_chmod gives you file operations as builtins. When $PATH is destroyed or /usr is unmounted, you may have a real shot at fixing a broken box box.
FIN
We’ll close with three final nuggets.
First, you can totally create your own Zsh modules! I refuse to drop some C code on folks, so let me introduce you to this clever Rust crate (docs) that lets you build Zsh modules safe-r-ly in Rust. The example is a fully working bit of code, so you have a place to start hacking on.
Second, impress your friends and neighbors with your very own HTTP server in Zsh:
zmodload zsh/net/tcp zsh/system zsh/datetime zsh/parametert0=$EPOCHREALTIMEztcp -l 8777 || exit 1lfd=$REPLYprint -r "http://127.0.0.1:8777/ -- Ctrl-C to stop"while ztcp -a $lfd; do # Put any shell stuff in here want -- i stuck w/zsh ones, but anything goes cfd=$REPLY sysread -i $cfd -s 4096 _ # swallow the request, we don't care what it asked syswrite -o $cfd "HTTP/1.0 200 OKContent-Type: text/plain$HOSTuptime $(( EPOCHREALTIME - t0 ))smodules $#modulesfuncs $#functions" ztcp -c $cfddone
(Save that to something like httpd.zsh and run it that way; choose a different port if that one is used up already.)
And, finally, you can learn more about these beasts on your own system via man zshmodules and poking around /usr/lib/zsh/VERSION/zsh.
If you end up doing something (anything!) with Zsh modules, drop me a note and make sure to share what you’ve made with others!
FIN
Remember, you can follow and interact with the full text of The Daily Drop’s free posts on:
- Mastodon via
@dailydrop.hrbrmstr.dev@dailydrop.hrbrmstr.dev - Bluesky via
<https://bsky.app/profile/dailydrop.hrbrmstr.dev.web.brid.gy>
☮️
Leave a Reply