PhotoLab's folder browser shows only the images at the top level of the folder you select. Photos
filed in subfolders — by year, by day, by camera — never appear together. flatlink builds a
flat folder of symlinks to every image in a tree. Open that folder in PhotoLab and it's all there.
Your photos are never moved, copied or modified. The only thing the tool ever creates or removes is symlinks inside the folder you point it at, and that folder itself if it isn't there yet.
The command:
brew install tsvb/tap/flatlinkThe app, which does the same from a window and can update the links by itself after each import (see the app):
brew install --cask tsvb/tap/flatlink-appOr download flatlink-app-…-macos.zip from the latest release
and move Flatlink.app to Applications.
Both are universal (Apple silicon and Intel), signed and notarized, for macOS 14 or later. No Python or other runtime needed. macOS only: there is no Windows or Linux version.
flatlink ~/Pictures/Photos ~/Pictures/PhotoLab-Alllink 2026__2026-05-28__IMG_0001.CR3
link 2026__2026-05-29__IMG_0042.CR3
link X100VI__DSCF1234.RAF
created 3, kept 0, skipped 0, pruned 0 -> /Users/you/Pictures/PhotoLab-All
Then open ~/Pictures/PhotoLab-All in PhotoLab.
Run the same command again after each import: existing links are kept and only new photos are
linked. A link is named after the path below the source, joined with __, so
2026/2026-05-28/IMG_0001.CR3 becomes 2026__2026-05-28__IMG_0001.CR3 and the links sort by folder.
| Option | Effect |
|---|---|
-n, --dry-run |
Show what would change, and what would go wrong; change nothing. Try this first. |
--skip-paired-jpegs |
If you shoot RAW+JPEG, leave out each JPEG that has a RAW of the same name in the same folder, so every shot appears once — as its RAW. |
--prune |
Remove links into the source whose original photo has been deleted, and, with --skip-paired-jpegs, links to the JPEGs it now leaves out. Links to anything outside the source are left alone, and so is a link whose original can't be reached. If the source holds no images at all, nothing is removed: that is what an unplugged drive looks like. Nor is anything removed when the source is under /Volumes but no drive is mounted there, only a folder left on the startup drive. |
--ext EXT |
Only link files with this extension; repeatable (--ext cr3 --ext jpg) or as a list (--ext cr3,jpg). Each spelling counts: --ext jpg leaves out .jpeg. By default: JPEG, TIFF, HEIC, PNG and 24 RAW formats. |
-- |
What follows is the source and the destination, even if it starts with -. |
-h, --help |
Show the help. |
--version |
Show the version. |
--ext cr3, --ext .CR3 and --ext=cr3 all mean the same. An extension that isn't in the list
below is linked all the same, with a note saying so.
The formats linked by default
| Kind | Extensions |
|---|---|
| JPEG | jpg jpeg jpe |
| TIFF | tif tiff |
| HEIC | heic heif |
| PNG | png |
| RAW | 3fr arw cr2 cr3 crw dng erf fff gpr iiq mef mos mrw nef nrw orf pef raf rw2 rwl sr2 srf srw x3f |
Upper or lower case makes no difference.
Hidden files, the contents of packages (such as .photoslibrary), existing symlinks and the
destination folder itself are skipped. A file already in the destination is never replaced: it is
reported and left alone. So is a link that points somewhere else, with one exception: a broken link
that was made for the same photo, or that points into the source, is pointed at the photo again.
Every photo that is left out is named on stderr, with the reason:
| It says | What happened, and what to do |
|---|---|
skip (link name … belongs to …) |
Two photos with the same link name. Names are joined with __, so a/b__c.jpg and a__b/c.jpg both ask for a__b__c.jpg, and on a drive that ignores case so do IMG.JPG and img.jpg. The link stays with the photo it already leads to, or goes to the first by path; rename one of the others to bring it in. |
skip (real file in the way) |
A file with the link's name is already in the link folder. It is never replaced; move it out to let the link in. |
skip (link exists, points elsewhere) |
A link with that name leads to something else. It is left alone; remove it if it isn't one you need. |
unreadable |
A folder that can't be read, for lack of permission. Its photos are missing from the link folder until it can. |
failed |
A link name that is too long. A file name holds 255 bytes, which a very deep path can exceed; shorten the names of the folders above the photo. Any other reason a link could not be made or removed is given in the same way. |
| Status | Meaning |
|---|---|
0 |
Every image under the source has its link. |
1 |
Some images don't, no image was found, a link could not be removed or the link folder can't be used. Each is named on stderr. |
64 |
Usage error, a source that is not a folder or is /, or a destination that is the source. |
Remove the links and nothing else, which leaves the edits saved in the link folder where they are:
find ~/Pictures/PhotoLab-All -maxdepth 1 -type l -deleteTo remove the tool:
brew uninstall flatlink-
External drives: put the link folder on the same drive as the photos (
/Volumes/Photos/PhotoLab-All), so it travels with the drive. The drive must be formatted APFS or Mac OS Extended — exFAT can't hold symlinks. A link folder on your Mac works too, but PhotoLab shows the images as missing while the drive is unplugged. -
Moved photos, renamed drive: links hold the full path of the original, so they break when the photo folder moves or the drive mounts under another name. Run
flatlinkagain with the new location and the same link folder: every link is pointed at the new place, under the same name, so your edits stay attached.A broken link is matched to a photo by its path below the source, and the source it came from isn't recorded, so any folder with the same layout gets its links. Run a backup copy into a link folder of its own: run into your usual one while the main drive is unplugged, and its links, with the edits saved beside them, move to the copy. They stay there while the copy is reachable, since only a broken link is moved, and the main drive's run reports each as
skip (link exists, points elsewhere). To bring them back, eject or move the copy, then run the main drive again. -
One source per link folder: link names come from the path below the source, so two sources that both hold
IMG_0001.CR3compete for one link, and for the edits saved beside it. -
Large archives: a folder of many thousands of images can make PhotoLab slow to browse. Run the tool once per camera or per year into separate link folders if it does.
PhotoLab saves your edits in a .dop sidecar beside the link, named after it, not beside the
original. The original photo is never touched. (Measured in PhotoLab 9.12.)
That makes the link folder the home of your PhotoLab edits, so:
- Keep it. Don't delete and rebuild the link folder once you've edited in it; re-run
flatlinkinto the same folder instead. It only ever adds or removes symlinks, never a.dop. - Rename source folders with care. A link's name comes from its path below the source, so
renaming
2026-05-28/gives its photos new links, and PhotoLab sees them as unedited. The old.dopfiles stay in the link folder, under the old names. A photo that later arrives under one of those names takes over the old link, and the edits saved beside it. - Other apps won't see these edits, because they aren't beside the original. Export from PhotoLab to share the results.
PhotoLab 9 lists a folder through its Filesystem plugin, which calls NSFileManager's
enumeratorAtURL:includingPropertiesForKeys:options:errorHandler: with options 7: skip
subdirectory descendants, skip package descendants, skip hidden files. There is no setting to change
it; the same listing feeds both the folder tree and the image grid. The code that sorts the results
does resolve symlinks and aliases, which is what this tool relies on. (Measured in PhotoLab 9.12.)
swift build -c releaseswift testThe binary is .build/release/flatlink. Requires Xcode 16 or later. No dependencies. Releases are
built, signed, notarized and tagged by scripts/release.sh, with the Xcode named
in .xcode-version; CI builds and tests with the same one, on Apple silicon and,
under Rosetta, on Intel. scripts/publish.sh then pushes the tag, creates the
GitHub release and points the Homebrew casks at it.
Flatlink.app (install; source in App/) puts a window on the same code: keep a list of photo folders and their link folders, preview what would change, update with one click, and open the result in PhotoLab.
Adding a photo folder changes nothing on disk. The app suggests a link folder beside it (or inside it, for a drive's top folder), and the first links are made when you click Update Links or turn on Update automatically, which is off for a new folder. With it on, the app watches the photo folder and updates the links a few seconds after an import has finished: new photos are linked, and with pruning on, deleted ones unlinked. It catches up when it starts, when the switch is turned on and when the drive is plugged back in, and it keeps watching with its window closed, for as long as it runs. Turn on Open Flatlink at login in its Settings (⌘,) to have it start hidden when you log in and keep watching after a restart; it is listed, and can be switched off, in System Settings › General › Login Items. Only images or folders coming, going or being renamed count, so the files Capture One or PhotoLab write don't set it off.
cd App && xcodegen generate && open Flatlink.xcodeprojThe Xcode project is generated from App/project.yml by XcodeGen and not committed; releases and CI use the version in .xcodegen-version. The app is not sandboxed, for the same reason the command isn't: it reads whole photo trees and checks where every link leads. The icon is drawn by App/scripts/make-icon.swift, and App/scripts/open-as-login-item.swift opens a build the way macOS does at login, to try that without logging out.
MIT — see LICENSE.
Not affiliated with or endorsed by DxO. DxO and PhotoLab are trademarks of DxO Labs.
Made by Tim VanBenschoten.