JPEG XL on the Command Line: cjxl and djxl
The official tools, run for real on libjxl 0.12.0: how to install them on each system, the commands worth knowing, what quality and effort actually cost in bytes and seconds, two batch scripts we tested on awkward filenames, and the error messages you'll meet with their fix.
by Robert Castillo · Published
The short answer
JPEG XL's official command-line tools come from libjxl, the reference implementation of the format. Three programs cover almost everything: cjxl encodes, djxl decodes and jxlinfo tells you what's inside a file. The current release is 0.12.0, published on 1 July 2026, and every command, output and number on this page comes from running that version ourselves.
If you remember one command, make it this one: cjxl photo.jpg photo.jxl. Given a JPEG, cjxl doesn't compress the picture again — it repacks the JPEG's existing data more efficiently. On our test photo that saved 26.9%, and djxl rebuilt the original JPEG with an identical checksum, metadata and all.
The rest is detail, but some of it is surprising. PNG input is compressed lossily unless you ask otherwise, a quality flag on a JPEG is an error rather than a request, and the version Ubuntu 24.04 installs is four years old. All of that is below, with the fix.
How we tested
The photo
A 3506×2329 landscape shot on a Nikon D90 by David Marcu, released as CC0 on Wikimedia Commons: 3.66 MB with EXIF, XMP, IPTC and a ProPhoto colour profile. A real camera file, not a lab sample.
The machine
A Linux server with 6 virtual AMD EPYC cores, shared with other jobs. Read the timings as orders of magnitude: your computer will differ, the ratios between settings much less.
The versions
libjxl 0.12.0 (official static build), plus the 0.7.0 that Ubuntu 24.04 still ships, to show what changes. Windows commands ran on Windows 11 with PowerShell 5.1 and the official zip.
Installing cjxl, djxl and jxlinfo
Every major system has a package, but the version you get varies a lot. These are the versions each source offered on 15 October 2026:
| System | Command | Version |
|---|---|---|
| Windows (winget) | winget install libjxl.libjxl | 0.12.0 |
| Windows (Scoop) | scoop install libjxl | 0.12.0 |
| Windows (by hand) | jxl-x64-windows-static.zip from GitHub | 0.12.0 |
| Windows (Chocolatey) | No package | — |
| macOS (Homebrew) | brew install jpeg-xl | 0.12.0 |
| Arch Linux | sudo pacman -S libjxl | 0.12.0 |
| Fedora 43 and 44 | sudo dnf install libjxl-utils | 0.11.2 |
| Debian 13 | sudo apt install libjxl-tools | 0.11.2 |
| Ubuntu 25.10 and 26.04 LTS | sudo apt install libjxl-tools | 0.11.1 |
| Ubuntu 24.04 LTS, Debian 12 | sudo apt install libjxl-tools | 0.7.0 (2022) |
Windows. winget installs the official zip as a portable app and adds its folder to your user PATH, so close the terminal and open a new one before typing cjxl --version. To do it by hand, download jxl-x64-windows-static.zip from the 0.12.0 release page and unzip it: the programs are in x64-windows-static\bin. Either add that folder to PATH or call cjxl.exe by its full path. The zip winget downloads is the same file, checksum for checksum.
macOS. The Homebrew package is called jpeg-xl, not libjxl. It installs cjxl, djxl, jxlinfo and jxltran, plus libjxl's developer tools (ssimulacra2 and butteraugli_main among them), built from the 0.12.0 sources.
Check what you got. cjxl --version answers with a line like cjxl v0.12.0 4128790 [_AVX2_,SSE4,SSE2]; the bracket lists the CPU instruction sets the build is using. Don't try the same on jxlinfo, which has no version flag (see the errors table).
Ubuntu 24.04 and Debian 12: getting past version 0.7
Ubuntu 24.04 LTS and Debian 12 still install libjxl 0.7.0, from 2022. It works — in our tests it even decoded files made by 0.12.0 perfectly — but it lacks effort 10, djxl's --reconstruct_jpeg safety switch and four years of fixes, including the long list of security hardening in the 0.12.0 notes. It also treats quality flags on JPEGs differently, which can quietly change what a script does (explained further down).
The cleanest route to 0.12.0 is the official static build: one archive, self-contained programs, nothing in the system libraries touched. It's exactly how our test server is set up. Run these one at a time:
| Bash · Ubuntu / Debian |
|---|
sudo apt install lzip |
The archive has no top-level folder — the files sit at its root — which is why it gets a directory of its own. /usr/local/bin comes before /usr/bin in Ubuntu's default PATH, so these links win even if libjxl-tools is installed too; open a new terminal (or run hash -r) and the version line should say v0.12.0. The release also has .deb packages for Ubuntu 24.04, named jxl and libjxl. They put cjxl in /usr/bin, the same place as libjxl-tools, so remove the distribution package first if you go that way.
Repack a JPEG without losing a single bit
This is the conversion most people are here for, and the one where JPEG XL has no rival: smaller files with nothing given up, reversible at any time.
Convert
cjxl foto.jpg foto.jxl prints Encoding [JPEG, lossless transcode, effort: 7] and Compressed to 2671.6 kB including container. Our 3,655,400-byte photo became 2,671,641 bytes, 26.9% smaller, in under a second.
Get the JPEG back
djxl foto.jxl back.jpg answers Reconstructed to JPEG. It took 0.3 to 0.4 seconds on our server. That message is the one to look for: it means the original file was rebuilt, not a new one encoded.
Prove it
Compare checksums: sha256sum on Linux, shasum -a 256 on a Mac, Get-FileHash in PowerShell. Both files gave 7cf96c2b…534b, on Linux and on Windows alike.
That last step is the point. This isn't "visually identical": it's the same file, byte for byte, EXIF and colour profile included. On Linux or macOS you can check without writing a temporary file: djxl foto.jxl - --output_format jpg | cmp - foto.jpg prints nothing when they match.
cjxl adds a note on every JPEG, Implicit-default for JPEG is lossless-transcoding, just to tell you what it chose. Add --lossless_jpeg=1 to make the choice explicit and the note goes away. On the decoding side, 0.12.0 adds --reconstruct_jpeg (-J): instead of quietly producing a new JPEG when the original can't be rebuilt, djxl stops with Error: could not decode losslessly to JPEG.
Compatibility runs backwards too. Ubuntu's old djxl 0.7.0 rebuilt our 0.12.0 file into the identical JPEG. The format itself was standardised in 2022 (ISO/IEC 18181); what keeps moving is the tools.
-d and -q: how to ask for quality
For anything that isn't a JPEG, cjxl compresses the pixels, and you decide how much loss to accept. There are two ways to say it. -d (distance) is the native one: 0 is mathematically lossless, 1 is the encoder's idea of visually lossless, and higher numbers allow more distortion, up to 25. -q (quality) is a 0–100 scale meant to feel like JPEG's, which cjxl simply turns into a distance. Use one or the other; both together is an error.
We encoded our photo (as a 9.85 MB PNG) at common quality values and scored each result with SSIMULACRA2, the score in the table, where 90 means visually lossless, 70 high quality and 50 medium. From quality 30 upwards the conversion is a straight line, distance = 0.1 + (100 − q) × 0.09:
| Setting | Size | Score |
|---|---|---|
| -q 100, lossless | 6.49 MB | Identical pixels |
| -q 95 = -d 0.55 | 1.41 MB | 89.9 |
| -q 90 = -d 1 (PNG default) | 0.80 MB | 86.7 |
| -q 85 = -d 1.45 | 0.58 MB | 83.4 |
| -q 80 = -d 1.9 | 0.47 MB | 80.2 |
| -q 70 = -d 2.8 | 0.34 MB | 73.6 |
| -q 50 = -d 4.6 | 0.21 MB | 60.8 |
Below 30 the curve bends: quality 20 gives distance 9.07, quality 10 gives 15.27 and quality 0 the maximum, 25. Nobody needs those for photos.
For everyday use, -d 1 (or -q 90) is a sound default, and -d 0.5 to 0.7 is where to go for a high-quality master you'll edit later. The recommended range printed by cjxl --help is 0.5 to 3.
The trap: quality flags on a JPEG
Try cjxl foto.jpg foto.jxl -d 1 on version 0.12.0 and nothing is written. You get Must not set non-zero distance in combination with --lossless_jpeg=1, which is set by default. (with -q the wording is quality below 100). cjxl is protecting the lossless repack: a distance makes no sense when it isn't touching the pixels.
If you really want a smaller, lossy file from a JPEG, say so: cjxl foto.jpg foto.jxl --lossless_jpeg=0 -d 1. Our photo came out at 800,133 bytes, under a third of the lossless repack. The price is a second round of loss on a picture that was already compressed once, and, as the metadata section shows, part of the metadata. For an archive, the lossless 27% is the better deal.
Version 0.7.0 behaves differently, and this is the part that bites: there, -d 1 on a JPEG is silently ignored. Our test command exited without an error and wrote a lossless repack. So a script that "worked" on Ubuntu 24.04 fails on 0.12.0, and one that relied on -d to shrink JPEGs never shrank them. Writing --lossless_jpeg=0 or =1 explicitly behaves the same on both versions.
Effort (-e): what the extra seconds buy
-e sets how hard the encoder works, from 1 to 10, with 7 as the default. Here are two jobs on the same photo: repacking the 3.66 MB JPEG, and encoding its 9.85 MB PNG version at -d 1. Each cell gives file size and wall-clock time, the median of three runs (lossless gets its own table in the next section):
| Effort | JPEG repack | PNG, lossy |
|---|---|---|
| 1 | 2.867 MB · 0.6 s | 0.784 MB · 0.7 s |
| 2 | 2.867 MB · 0.6 s | 0.784 MB · 0.6 s |
| 3 | 2.672 MB · 0.6 s | 0.725 MB · 0.6 s |
| 4 | 2.672 MB · 0.7 s | 0.767 MB · 0.6 s |
| 5 | 2.672 MB · 0.7 s | 0.782 MB · 1.0 s |
| 6 | 2.672 MB · 0.7 s | 0.797 MB · 2.5 s |
| 7 (default) | 2.672 MB · 0.9 s | 0.800 MB · 2.1 s |
| 8 | 2.666 MB · 1.8 s | 0.754 MB · 23.6 s |
| 9 | 2.665 MB · 11.6 s | 0.753 MB · 36.7 s |
| 10 | 2.668 MB · 12.3 s | 0.752 MB · 34.7 s |
Read it across rather than down. For JPEG repacking, efforts 3 to 7 produce exactly the same file, and 8 and above save 0.2% for two to fourteen times the wait; effort 10 even came out a little larger than 9. For lossy encoding, effort 3 makes the smallest file but scores lower (SSIMULACRA2 85.5 against 86.7 at the default), while efforts 8 to 10 make files about 6% smaller than 7 at practically the same score (86.4–86.5), for eleven to seventeen times the time.
So leave it at 7 for almost everything. Effort 10 doesn't exist in 0.7.0, and 0.12.0 also has an effort 11 reserved for experts: without --allow_expert_options it answers Valid range is {1, 2, ..., 10}.
Lossless from PNG means -d 0
The default for PNG, APNG, PPM and other pixel formats is -d 1: visually lossless, but lossy. That's fine for photos you'll only look at, and wrong for screenshots, scans, artwork or anything you're archiving, where you want every pixel kept. Pass -d 0 (or -q 100).
Our photo saved as PNG weighed 9,854,523 bytes. At -d 0 it came out like this at each effort (median of three runs up to 7, one run from 8 to 10):
| Effort | Size | Time |
|---|---|---|
| 1 | 8.373 MB | 0.4 s |
| 2 | 7.110 MB | 0.6 s |
| 3 | 6.826 MB | 0.9 s |
| 4 | 6.782 MB | 1.4 s |
| 5 | 6.852 MB | 2.0 s |
| 6 | 6.795 MB | 2.5 s |
| 7 (default) | 6.489 MB | 3.8 s |
| 8 | 6.218 MB | 16.7 s |
| 9 | 6.190 MB | 24.8 s |
| 10 | 6.101 MB | About 5.5 min |
At the default effort that's 34% smaller than the PNG, and after decoding it again ImageMagick's compare -metric AE counted 0 different pixels. Lossless is where effort pays most steadily: e9 is 4.6% smaller than e7 for six and a half times the wait, and e10 takes another 1.4% for five and a half minutes on a single photo (324 and 337 seconds in two separate runs). Use 9 for an archive you'll create once and keep for years.
GIF is the exception that goes the other way: cjxl encodes GIF input losslessly by default, as the animation section shows.
jxlinfo: see what's inside a file
Before converting a .jxl back, it's worth knowing what kind it is. This is what jxlinfo says about our repacked JPEG:
| What jxlinfo prints |
|---|
JPEG XL file format container (ISO/IEC 18181-2) |
The line that matters most is JPEG bitstream reconstruction data available: as long as it's there, djxl can hand you back the original JPEG. A lossy file says lossy instead of (possibly) lossless and has no reconstruction line; an animation starts with JPEG XL animation and lists every frame with its duration. Add -v to see the container boxes too. In 0.12.0 jxlinfo was rewritten and takes only -v and -h.
Metadata: what survives and what doesn't
Our test photo carries EXIF (with camera and date), XMP, IPTC and a ProPhoto ICC profile. For the GPS and keyword checks we added those to a second, small JPEG with exiftool. This is what came out of each route:
| Route | Survives | Lost |
|---|---|---|
| JPEG → JXL repack → JPEG | Everything: EXIF and GPS, XMP, IPTC, ICC profile | Nothing |
| JPEG → JXL repack → PNG | EXIF and GPS, XMP, ICC profile | IPTC |
JPEG → lossy JXL (--lossless_jpeg=0) | EXIF and GPS; the ICC profile becomes a built-in colour description | XMP, IPTC |
| PNG with EXIF and XMP → JXL at -d 1 | EXIF, XMP; ICC profile converted as above | Nothing else to lose |
Lossless repacking is the safe route: the whole JPEG comes back, so every byte of metadata does too. exiftool reads the EXIF and XMP straight from the .jxl; the IPTC block and the original ICC profile travel inside the reconstruction data and reappear when you rebuild the JPEG.
The surprise is lossy re-encoding of a JPEG: cjxl 0.12.0 kept the EXIF, GPS position included, but dropped the XMP and the IPTC. If your keywords, ratings or Lightroom adjustments live in XMP, put it back explicitly: exiftool -xmp -b foto.jpg > foto.xmp, then add -x xmp=foto.xmp to the cjxl command. We checked that the creator field survives that way.
To remove GPS and other EXIF before sharing, use -x strip=exif. On a JPEG it refuses with Cannot strip exif metadata, try setting --allow_jpeg_reconstruction=0., because the original could no longer be rebuilt byte for byte. Add that flag and it works: the image is still repacked losslessly, but djxl now reports Decoded to pixels. and writes a new JPEG instead of the original. And to pull metadata out of a .jxl, just name the output by type: djxl foto.jxl foto.exif or djxl foto.jxl foto.xmp.
Animated GIF and APNG
cjxl reads animated GIF and APNG and keeps every frame and its timing. We made three 60-frame, 480×270 animations with ffmpeg and converted each with the default settings and with the opposite choice:
| Animation | cjxl default | The other way |
|---|---|---|
| GIF, flat test pattern: 646 KB | 606 KB, lossless (−6%) | -d 1: 1,177 KB (+82%) |
| GIF, pan across the photo: 6,074 KB | 5,158 KB, lossless (−15%) | -d 1: 1,663 KB (−73%) |
| APNG, flat test pattern: 1,009 KB | 990 KB, lossy (−2%) | -d 0: 620 KB (−38%) |
Two defaults explain that table. GIF input is encoded losslessly by default, and whether lossy helps depends entirely on the content: on flat graphics it made the file 82% bigger, on photographic frames 73% smaller. APNG, like PNG, defaults to lossy -d 1, and on our graphics animation that was the worst option, 60% larger than lossless. Try both on a couple of your own files before converting a folder.
Getting animations back out: djxl writes all the frames to an animated PNG whether you name the output .apng or .png (we counted 60 frames in both). It can't write GIF — it answers can't decode to the file extension '.gif'. — so convert that APNG with ffmpeg or ImageMagick if you need a GIF again.
Progressive files (-p)
-p reorders the file so that a partial download already shows the whole picture at reduced detail. It costs a little: our lossy photo grew from 799,774 to 852,155 bytes (+6.5%). To see what that buys, we cut both versions short, decoded what was left with djxl --allow_partial_files and scored it against the original:
| Received | Normal | With -p |
|---|---|---|
| 20% | No image yet | No image yet |
| 40% | −8.1 | 16.6 |
| 60% | 1.4 | 65.6 |
| 75% | 13.5 | 71.7 |
| 90% | 34.0 | 78.0 |
| 100% | 86.7 | 87.4 |
Without -p the image still fills the frame early, but most of it stays a rough placeholder until nearly all the data has arrived. With -p, 60% of the file already scores 65.6. It's worth it for images served on a website and pointless for an archive. It works on JPEG repacks too: our photo grew 1.9% and still came back byte-identical.
Convert a whole folder on Linux or macOS
This script converts the .jpg, .jpeg and .png files in one folder (not its subfolders) to .jxl, losslessly, and leaves the originals where they are. Save it as to-jxl.sh and run bash to-jxl.sh ~/Pictures/holiday:
| Bash · Linux and macOS |
|---|
#!/usr/bin/env bash |
What it does: JPEGs are repacked losslessly and PNGs encoded with -d 0, so nothing is lost either way. Any image whose .jxl already exists is skipped, so you can stop it and run it again. Each file is written to a temporary .part first and only renamed once cjxl has finished, so an interrupted run never leaves a half-written .jxl that the next run would mistake for a finished one. The new file gets the original's date, and a failure is reported without stopping the loop. Upper-case extensions such as .JPG are included.
We ran it twice on a folder with deliberately awkward names — spaces, an ñ, square brackets, Cyrillic letters, an upper-case .JPG — plus a .jxl that already existed and a "photo" that was really a text file. The second run skipped everything already done and reported the fake again. On a Mac, run it with bash as shown rather than pasting it into the default zsh terminal: shopt is a bash command. To check every JPEG afterwards, open a terminal inside the folder and paste this line; the bash -c wrapper makes it work the same from bash or zsh: bash -c 'shopt -s nullglob nocaseglob; for f in *.jpg *.jpeg; do djxl "${f%.*}.jxl" - --output_format jpg --quiet | cmp -s - "$f" && echo "identical: $f" || echo "DIFFERENT: $f"; done'
Convert a whole folder on Windows (PowerShell)
The same job for Windows. Change the path on the first line and paste the whole block into a PowerShell window. Pasting, rather than saving it as a .ps1 file, sidesteps Windows' default policy that blocks scripts from running:
| PowerShell · Windows |
|---|
$folder = "C:\Photos" |
It behaves exactly like the bash version, and we tested it the same way: twice on the awkward-names folder, in Windows PowerShell 5.1 on Windows 11 and in PowerShell 7.6 on Linux. -LiteralPath is what stops names with square brackets from being read as wildcards, and the -in comparison ignores case, so .JPG files are picked up. To check one result by hand: djxl foto.jxl check.jpg, then (Get-FileHash foto.jpg).Hash -eq (Get-FileHash check.jpg).Hash should print True.
One limit applies to both scripts: if photo.jpg and photo.png sit side by side, both want photo.jxl, so the second is skipped. Rename one of them first.
Errors you'll meet, and the fix
Every message below is copied from our own runs. cjxl prints its version banner first; the error is the line after it.
| Message | Cause and fix |
|---|---|
Must not set non-zero distance in combination with --lossless_jpeg=1, which is set by default. | -d or -q used on a JPEG (0.12.0). Fix: add --lossless_jpeg=0 for a lossy file, or drop -d to repack losslessly. |
Getting pixel data failed. | The input isn't a format cjxl reads (we got it with WebP, TIFF and BMP) or the file is damaged. Fix: convert to PNG first, for example magick in.webp in.png. cjxl reads JPEG, PNG, APNG, GIF, PPM/PNM/PFM/PAM, PGX and JXL. |
Reading image data failed. | There's no file at that path. Fix: check the name, and put quotes around paths with spaces. |
JPEG bitstream reconstruction data could not be created. Possibly there is too much tail data. | In our tests, a CMYK JPEG (common in print work); 300 KB of trailing data was handled fine. Fix: convert the image to RGB first. 0.12.0 rejected the CMYK file by every route we tried. |
Invalid flag value for --effort: Valid range is {1, 2, ..., 9}. | You're on libjxl 0.7 (Ubuntu 24.04, Debian 12) and asked for -e 10. Fix: upgrade, or use -e 9. |
Unknown argument: --reconstruct_jpeg | djxl 0.7, which predates that option. Fix: upgrade, or leave the option out. |
can't decode to the file extension '.gif'. | djxl doesn't write GIF. Fix: decode to .apng and convert that with ffmpeg or ImageMagick. |
Cannot strip exif metadata, try setting --allow_jpeg_reconstruction=0. | -x strip=exif on a JPEG repack. Fix: add the flag; the original JPEG will no longer be recoverable byte for byte. |
Must not set both --distance and --quality. | -d and -q in the same command. Fix: keep one of them. |
Failed to read file: --version | jxlinfo has no version flag and took it for a file name. Fix: use cjxl --version. |
The term 'cjxl' is not recognized… | PowerShell can't find cjxl on the PATH. Fix: open a new terminal after installing with winget, or call cjxl.exe by its full path. |
…cannot be loaded because running scripts is disabled on this system | You saved the PowerShell loop as a .ps1 file. Fix: paste it instead, or run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned once. |
tar (child): lzip: Cannot exec: No such file or directory | lzip isn't installed, so the static build can't be unpacked. Fix: sudo apt install lzip. |