fx is slower than jq. On a 37 MB array of 600,000 objects it took about 2.5 times as long and peaked near three times the memory, so if raw speed on big files is the goal, keep jq. What you get instead is the fx JSON viewer, which you can scroll, fold and query inside the terminal, plus filters written in plain JavaScript, which most people can type without opening a manual.
This guide covers installing the fx command line JSON tool from its release binary and browsing files in its interactive viewer. From there you will query JSON and YAML with JavaScript, read JSON lines from journald, and fix the errors we hit. It ends with the fx versus jq numbers and when we’d reach for each. Everything below ran on Debian 13 with fx 40.0.0 and Debian’s jq 1.7.1 (which reports itself as jq-1.7) in October 2026.
Install fx from the release binary
fx no longer needs Node.js. Current releases are a single statically linked Go binary of about 22 MB, so installing it means downloading one file and putting it on your PATH. The project publishes six builds on every release, and the asset name only changes by OS and CPU, which is why one URL pattern covers all of them.
fx_linux_amd64andfx_linux_arm64fx_darwin_amd64andfx_darwin_arm64for macOSfx_windows_amd64.exeandfx_windows_arm64.exe
GitHub’s releases/latest/download/ path always resolves to the newest tag, so you do not have to look the version up first. On Debian and Ubuntu, dpkg --print-architecture already prints amd64 or arm64, which matches the asset suffix exactly. On a distro without dpkg, set ARCH=amd64 or ARCH=arm64 by hand before running the rest.
ARCH=$(dpkg --print-architecture)
curl -fsSLo fx "https://github.com/antonmedv/fx/releases/latest/download/fx_linux_${ARCH}"
sudo install -m 0755 fx /usr/local/bin/fx
rm fx
Check the version and what kind of file landed in /usr/local/bin. You want a version string back and statically linked in the file output, because that is what lets you copy the same binary to any Linux box of the same architecture without pulling in libraries:
fx --version
ls -la /usr/local/bin/fx
file /usr/local/bin/fx
Our Debian 13 VM printed the version, a 22 MB file owned by root, and a static Go executable. If curl stops with an error before that, check the ARCH value, because only amd64 and arm64 builds exist for Linux.
40.0.0
-rwxr-xr-x 1 root root 22306758 Oct 18 03:46 /usr/local/bin/fx
/usr/local/bin/fx: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=qO8-7TZj2QzZuBEJglUn/cU7rW86ajC5joaSJD20X/PMPI0pyESqzKg4PGh3F4/k0B-R1P3zLCy6MWCAjln, BuildID[sha1]=807d9006ca4b33e087a28d95c6fb569759531f65, with debug_info, not stripped
The project also offers a one-line installer at fx.wtf. We read it before running anything: it hardcodes the release number inside the script, downloads the matching asset with curl, then runs sudo mv into /usr/local/bin. That gives the same result as the commands above, but you are piping a script that asks for root into your shell, so we’d stick with the direct download where you can see every step.
The same page still lists npm install -g fx, but that installs the JavaScript edition, which only processes JSON and has no interactive viewer, so skip it if the viewer is why you came. Upstream also documents snap, Homebrew, pacman and go install routes; we tested none of them, only the release binary above.
Open the fx JSON viewer
Run fx with a file and no query and it opens a full-screen viewer instead of printing. That viewer is the reason to install fx at all, because jq has nothing like it. To get a realistic file to look at, save the last five fx releases from the GitHub API and trim each one down to its tag, date and asset sizes. The URL is quoted on purpose, which the errors section explains:
curl -s "https://api.github.com/repos/antonmedv/fx/releases?per_page=5" > rel.json
fx --no-tui rel.json "x => x.map(r => ({tag: r.tag_name, date: r.published_at, assets: r.assets.map(a => ({name: a.name, size: a.size}))}))" > releases.json
The --no-tui flag isn’t strictly needed here, because fx prints and exits on its own whenever you give it a query. It matters when you call fx on a file with no query and still want plain output, as in a script.
Now open the trimmed file in the viewer. Without a query, fx takes over the terminal until you press q:
fx releases.json
The file opens fully expanded, with a position counter and the file name in the status bar. Press E to collapse everything and you get one line per release, each showing its first key, which is the fastest way to see the shape of a large document before you dig in:
[
{"tag":"40.0.0",…},
{"tag":"39.2.0",…},
{"tag":"39.1.0",…},
{"tag":"39.0.4",…},
{"tag":"39.0.3",…}
]
Press ? for the full key list. Navigation is vim-style (j, k, g, G), and the keys you’ll actually use day to day are these:
lorhto expand or collapse a node,LandHto do it recursively1to9to collapse to that nesting level.to open the query input, with tab completion on keysyto copy a node,Pto print it to stdout:w fileto write the current view to a file,qto quit
The . query box is where the viewer earns its keep. You type the same expressions covered in the next section, press Enter, and the view narrows to the result, and pressing esc in the query box cancels the query and restores the original document. Colours come from FX_THEME; run fx --themes to preview each numbered theme on a sample object and export the one you like in your shell profile.
Run JavaScript queries on JSON
Every argument after the file name is a query, and fx runs them left to right, feeding each result into the next. A query that starts with a dot is a property path, and anything else is JavaScript where x is the input. The output of ip -j is a handy real file to practise on, because every Linux box has one. Save it and count the interfaces:
ip -j addr show > ip.json
fx ip.json ".length"
fx ip.json ".[0].ifname"
Our VM has a loopback and one NIC, so the count is 2 and the first interface is lo. Indexes start at 0, as they do in JavaScript, so .[1] would be the NIC.
2
lo
To pull one field out of every element you can write the JavaScript map yourself, or use the @ shorthand, which means “map over the array with this expression”. Both give the same list, and the shorthand is what you’ll type once it sticks:
fx ip.json ".map(x => x.ifname)"
fx ip.json "@.ifname"
Each prints the same array. The only difference is typing, because @ saves you the x => boilerplate on every map.
[
"lo",
"ens3"
]
The ? prefix is the matching shorthand for filter. Chained with @ and an ordinary JavaScript .flat(), it answers a real question in one line: which addresses are on interfaces that are up. The loopback is not reported as UP, so only the NIC’s addresses survive.
fx ip.json "?.operstate == \"UP\"" "@.addr_info" ".flat()" "@.local"
Four queries ran in sequence there, and each one’s output became the next one’s input. That is how you build these up: run the first, look at the result, then add the next. It returned the IPv4 address and the link-local IPv6 address of ens3:
[
"10.0.1.118",
"fe80::f816:3eff:fea9:9c37"
]
Because a query can be any arrow function, you can also build plain text output for a report or a shell loop. This one turns the saved GitHub API response into one line per release with the tag and the date, using nothing but map, slice and join:
fx rel.json "x => x.map(r => r.tag_name + \" \" + r.published_at.slice(0,10)).join(\"\\n\")"
A string result prints raw, without quotes, so it drops straight into while read or a file. Arrays and objects still print as formatted JSON.
40.0.0 2026-10-05
39.2.0 2025-11-12
39.1.0 2025-09-07
39.0.4 2025-08-31
39.0.3 2025-08-28
That’s the trade fx makes. jq’s own language is shorter once you know it, but the expressions above are JavaScript you already read every day, and you can test them in a browser console if one misbehaves.
Use the built-in shortcuts
For the common cases you don’t need JavaScript at all. fx understands .items[].name to map over an array, and it ships bare-word functions (len, keys, sort and uniq) that you pass as separate arguments after a path. These read almost like jq, which helps if you switch between the two:
fx releases.json ".[].tag"
fx releases.json ".[0].assets" len
fx releases.json ".[0]" keys
The screenshot shows the version check from the install step, the .length and @.ifname queries on ip.json, and the .[].tag and keys queries on the releases file. All of these also work in the viewer’s . query box:

The functions also mix with expressions. Here the sizes of the newest release’s assets are mapped, sorted ascending and cut to the two smallest, which turn out to be two of the three arm64 builds (Linux and Windows):
fx releases.json ".[0].assets" "@.size" sort ".slice(0,2)"
Both numbers are bytes, so each arm64 binary is about 21 MB. The amd64 build is a little larger, which matches the 22306758-byte file we installed.
[
21018603,
21131776
]
Shell completion is built into the binary as well. fx --comp zsh prints a zsh completion function that starts with #compdef fx. We printed it but did not wire it into a profile, so load it the way you load your other completions. If your zsh still lacks completion for other tools, our guide to enabling completion in zsh covers the groundwork.
Read YAML and JSON lines
fx reads YAML when you pass --yaml, and the same queries work on it, which makes it a reasonable stand-in for yq on a box where you only want one tool. Create a small config file to try it:
printf "%s\n" "server:" " port: 8080" " tls: false" "users:" " - name: ana" " role: admin" " - name: ben" " role: dev" > app.yaml
A path pulls one value, and the filter shorthand finds the admins. Both queries are exactly what you would run against the same data in JSON.
fx --yaml app.yaml ".server.port"
fx --yaml app.yaml ".users" "?.role == \"admin\"" "@.name"
The port comes back as a number, not a string, and the filter returns a one-item array:
8080
[
"ana"
]
You can change values with an object spread, but the result comes out as JSON, not YAML. That’s fine for checking what a change would do. If the edited file has to go back to disk as YAML, though, fx is not the tool for that step.
fx --yaml app.yaml "x => ({...x, server: {...x.server, tls: true}})"
The tls key flipped and everything else came through untouched. Redirecting that output over app.yaml would replace your YAML with JSON, so write it somewhere else.
{
"server": {
"port": 8080,
"tls": true
},
"users": [
{ "name": "ana", "role": "admin" },
{ "name": "ben", "role": "dev" }
]
}
Log tools emit JSON lines, one object per line, rather than a single document. --slurp gathers those lines into one array so you can sort and count across them. This counts the last 200 journal entries by the command that wrote them and keeps the top four, which is a quick way to see who is filling your logs. Run it with sudo or as a member of the adm or systemd-journal group, because other non-root users only see their own journal entries. For the filtering side, our journalctl filtering guide goes further:
journalctl -o json -n 200 --no-pager | fx --slurp "x => Object.entries(x.reduce((a,e)=>(a[e._COMM||\"?\"]=(a[e._COMM||\"?\"]||0)+1,a),{})).sort((a,b)=>b[1]-a[1]).slice(0,4)"
On our lab VM, SSH sessions and sudo dominated, which is what you’d expect on a box being driven over SSH:
[
[
"sshd-session",
64
],
[
"sudo",
54
],
[
"systemd",
36
],
[
"conmon",
31
]
]
This one-liner is also where jq would be shorter (group_by does the counting for you). We kept it in JavaScript to show the ceiling: when the built-ins run out, you still have the whole language.
Fix common fx errors
Three things went wrong while we tested, and each one looks like an fx bug until you see the cause. None of them needed a change to fx itself. The first one is specific to zsh, the login shell on our lab VM. If you want the same shell, our zsh and Oh My Zsh install guide sets it up.
zsh: no matches found with a GitHub API URL
Our first attempt to fetch the release list ran an unquoted URL from zsh, and the shell refused it before curl even started:
zsh:1: no matches found: https://api.github.com/repos/antonmedv/fx/releases?per_page=5
zsh treats ? as a glob character, finds no file that matches the URL, and refuses to run that command, while bash would have passed the text through. Because the command never runs, the redirect never happens and no output file is created. Run interactively, the fx command that follows then fails with open rel.json: no such file or directory rather than a JSON error. Quote every URL that has a query string. If you’ve moved to zsh recently, this is one of the habits our comparison of Linux and macOS shells warns about.
Empty input: expected JSON value.
fx prints Empty input: expected JSON value. when the file it is given has zero bytes, or when the command feeding its pipe printed nothing. We hit it when rel.json was missing: fx treated the name as a query and read an empty stdin instead. When you see this message, check the file size or run the upstream command on its own before you look at your query, because fx never got any data to run it on.
fx prints undefined
Running fx --yaml app.yaml "@.name" printed undefined and nothing else. The top of that file is an object with server and users keys, not an array of people, so there is no name at the level the query looked. Add the path to the array first (".users" "@.name"), or open the file in the viewer and press . to let tab completion show which keys exist where.
fx or jq: pick by the job
To put a number on the speed question we generated a 37 MB file of 600,000 log-like records with Python, installed jq and GNU time, and counted the records with status 500 three times with each tool. The VM had a single vCPU, so read the results as the ratio on one core, not as absolute speeds you should expect on your hardware:
sudo apt-get install -y jq time
python3 -c "import json,random;random.seed(1);print(json.dumps([{\"id\":i,\"host\":\"web%02d\"%(i%40),\"status\":random.choice([200,200,200,301,404,500]),\"bytes\":random.randint(100,90000)} for i in range(600000)]))" > big.json
/usr/bin/time -f "jq %es %MKB" jq "[.[] | select(.status==500)] | length" big.json
/usr/bin/time -f "fx %es %MKB" fx big.json "x => x.filter(r => r.status == 500).length"
/usr/bin/time -f "fx-map %es %MKB" fx big.json "?.status == 500" ".length"
Both tools found 99,750 matches every time. The last command, run once, uses fx’s ? filter shorthand instead of the arrow function. Each run cost this much, with peak resident memory in kilobytes as GNU time reports it:
| Run | jq time | jq peak RSS | fx time | fx peak RSS |
|---|---|---|---|---|
| 1 | 1.67 s | 457976 KB | 4.18 s | 1272696 KB |
| 2 | 1.66 s | 457332 KB | 4.15 s | 1245120 KB |
| 3 | 1.63 s | 458656 KB | 3.89 s | 1309460 KB |
fx ?.status == 500 shorthand | 5.22 s | 1484640 KB |
jq won every run by 2.4 to 2.5 times and stayed near 450 MB of RAM while fx needed 1.2 to 1.4 GB, almost three times as much. The shorthand filter was the slowest of all at 5.22 seconds in its single run, so on big files the arrow function is the better habit. The terminal capture shows the first round:

Memory is the number that decides it on servers. We didn’t run it on a 1 GB VM, but with fx peaking above 1.2 GB we’d expect the fx run to fail there, while the jq one should fit. A log file a few times larger than ours would push fx past what most small instances have. So jq stays in every script, cron job and pipeline we write.
fx earns its place on your workstation, and on servers where you read JSON rather than process it in bulk. Opening an unfamiliar API response, collapsing it to see its shape, and trying queries in the . box until the right one appears is faster in fx than any amount of jq trial and error. Install both: use fx to find the query, then write it in whichever tool will run it unattended. If you spend your day in zsh, command autosuggestions make those long fx one-liners much easier to recall the second time.