cheat sheet
duti
Drive macOS Launch Services from the command line — assign default apps for UTIs, file extensions, and URL schemes; understand the viewer/editor/all/shell/none roles; bulk-load file associations from a settings file; rebuild the Launch Services database when changes refuse to stick.
duti — Set Default Applications on macOS
What it is
duti is a small command-line wrapper around macOS Launch Services that lets you assign which application opens a given document type, file extension, or URL scheme — the same machinery driven by Finder's Get Info → Open with → Change All, but scriptable. Bundle IDs go in (com.microsoft.VSCode), Uniform Type Identifiers or extensions go in (public.plain-text, .md), a role goes in (viewer, editor, all, shell, none), and Launch Services records the binding. It was written by Andrew Mortensen in 2008 and the upstream repo is explicitly marked unsupported, but the original moretension/duti build (currently 1.5.3 on Homebrew) still works through macOS 26 (Tahoe) — Launch Services itself has not had a breaking API change in years, so the binary's behaviour is unchanged across Sequoia → Tahoe. When it doesn't, the cause is almost always the Launch Services cache (covered below) rather than duti itself. Newer alternatives include dutis (a Go reimplementation) and dutix (modern Go-based handler manager with migration tooling) — worth knowing about, but duti's install footprint is tiny and the syntax has been stable for over a decade.
Install
duti is not part of macOS — install via Homebrew. The formula has been current at 1.5.3 for several years; no breaking changes have shipped, and the binary is universal (arm64 + x86_64).
brew install duti
# Verify
which duti
duti -V
Output:
/opt/homebrew/bin/duti
duti-1.5.3
If you want the source instead, the canonical repo is moretension/duti on GitHub — autotools build (./autogen.sh && ./configure && make && sudo make install).
git clone https://github.com/moretension/duti.git
cd duti
./autogen.sh && ./configure && make && sudo make install
Output: (none — exits 0 on success)
Syntax
duti has two argument shapes depending on whether you're setting a binding or querying one. Setting uses -s followed by three positional args (app bundle ID, type/extension/scheme, role); querying uses -d / -l / -x plus the thing you're asking about. A settings file (one binding per line) is the third shape.
# Set a default
duti -s BUNDLE_ID TYPE ROLE
duti -s BUNDLE_ID URL_SCHEME # URL schemes — role is implicit ("all")
# Query
duti -d UTI # which app currently handles this UTI?
duti -l UTI # list all apps that *could* handle this UTI
duti -x EXT # info about the default app for an extension
# Bulk load
duti SETTINGS_FILE # one binding per line; see "Settings files" below
Output: (none — exits 0 on success)
Essential options
| Option | Meaning |
|---|---|
-s BUNDLE TYPE ROLE | Set the default handler — bundle ID + UTI/extension + role |
-d UTI | Display the current default handler for a UTI |
-l UTI | List all registered handlers for a UTI (not just the default) |
-x EXT | Show app name, path, and bundle ID of the default handler for a file extension |
-v | Verbose — print what duti is sending to Launch Services |
-V | Print version and exit (capital V — -v is verbose) |
-h | Print usage |
| (no flag) | If the first positional argument is a file path, treat it as a settings file |
UTIs, bundle IDs, and roles — the mental model
Launch Services identifies file types with Uniform Type Identifiers — reverse-DNS strings like public.plain-text, public.html, com.adobe.pdf, public.mp3. Every extension on the system maps to one or more UTIs, and every UTI can have a default handler per role. Applications are identified by bundle IDs — also reverse-DNS, e.g. com.apple.Safari, com.microsoft.VSCode, org.videolan.vlc. The third leg is the role: which mode the app is being declared the default for.
# Find the bundle ID of an installed app — three ways, all reliable
osascript -e 'id of app "Safari"'
mdls -name kMDItemCFBundleIdentifier -raw /Applications/Safari.app
defaults read /Applications/Safari.app/Contents/Info CFBundleIdentifier
Output:
com.apple.Safari
com.apple.Safari
com.apple.Safari
The five roles duti accepts come straight from the Launch Services API. They are not equally common — most of the time you want all.
| Role | What it means | Typical use |
|---|---|---|
all | App handles every role for this UTI | The 99% case — "open with X for this filetype" |
viewer | App reads and displays only | A different editor handles editing |
editor | App reads and writes (implies viewer) | Pairing a viewer with an editor |
shell | App can execute the item | Rare; mainly .command / shell-script types |
none | App can't open it but provides an icon | Almost never used outside theming |
# Find the UTI for an extension — Launch Services answers via duti -x
duti -x md
Output:
TextEdit.app
/System/Applications/TextEdit.app
com.apple.TextEdit
If you need the UTI itself (not just the handler), pair mdls against a file of that type:
# Create a sample, then ask Spotlight for the UTI
touch /tmp/sample.md
mdls -name kMDItemContentType -raw /tmp/sample.md
mdls -name kMDItemContentTypeTree /tmp/sample.md
Output:
net.daringfireball.markdown
kMDItemContentTypeTree = (
"net.daringfireball.markdown",
"public.plain-text",
"public.text",
"public.data",
"public.item",
"public.content"
)
The tree is important: when you set a default for public.plain-text, every leaf UTI that inherits from it (net.daringfireball.markdown, public.python-script, public.shell-script, …) falls through to that default unless overridden by a more specific binding.
Setting a default app
The -s form takes three arguments — bundle, type-or-extension, role — and applies the binding immediately. duti accepts the type as either a UTI (public.html) or a leading-dot extension (.html) or even a bare extension (html). UTI form is more precise because it binds the abstract type instead of one extension at a time.
# Open all .md files in VS Code, by extension
duti -s com.microsoft.VSCode .md all
# Same thing by UTI — covers files that have no extension but the right Content-Type
duti -s com.microsoft.VSCode net.daringfireball.markdown all
# Verbose mode — see what Launch Services is being told
duti -v -s com.microsoft.VSCode public.python-script all
Output:
Setting default handler for net.daringfireball.markdown
app: com.microsoft.VSCode
role: all
Subtle behaviour: setting a default for a leaf UTI (net.daringfireball.markdown) does not affect its parent (public.plain-text), but setting the parent will propagate to every leaf that hasn't been overridden. So a single duti -s com.microsoft.VSCode public.plain-text all is usually what you want for "make VS Code the editor for everything textual."
# Make VS Code the editor for every text-ish file in one shot
duti -s com.microsoft.VSCode public.plain-text all
duti -s com.microsoft.VSCode public.source-code all
duti -s com.microsoft.VSCode public.python-script all
duti -s com.microsoft.VSCode public.shell-script all
Output: (none — exits 0 on success)
Querying current defaults
Three different queries answer slightly different questions. -d UTI returns the bundle ID of the current default handler (machine-friendly). -l UTI lists every app on the system Launch Services has registered as capable of handling that UTI (useful for choosing one). -x EXT is the human-friendly summary keyed on an extension instead of a UTI.
duti -d public.html
duti -d public.plain-text
duti -d com.adobe.pdf
Output:
com.apple.Safari
com.microsoft.VSCode
com.apple.Preview
# Which apps *could* open HTML?
duti -l public.html
Output:
com.apple.Safari
com.google.Chrome
org.mozilla.firefox
com.microsoft.VSCode
com.apple.TextEdit
# Friendly view, keyed by extension instead of UTI
duti -x html
duti -x pdf
duti -x sh
Output:
Safari.app
/Applications/Safari.app
com.apple.Safari
Preview.app
/System/Applications/Preview.app
com.apple.Preview
Terminal.app
/System/Applications/Utilities/Terminal.app
com.apple.Terminal
URL scheme handlers
URL schemes (http, https, mailto, feed, ftp, irc, tel, custom app schemes like vscode://) are handled by Launch Services through a different table from document UTIs. The -s form for a URL scheme takes only two arguments — bundle ID and scheme name — because the role is always implicitly "the app that opens this URL."
# Make Firefox the default browser (both http and https — Apple separates them)
duti -s org.mozilla.firefox http
duti -s org.mozilla.firefox https
# Make Mimestream the default mail client
duti -s com.mimestream.Mimestream mailto
# Send RSS feed:// links to NetNewsWire
duti -s com.ranchero.NetNewsWire-Evergreen feed
# Custom app scheme — open vscode:// links in VS Code (already the default, shown for shape)
duti -s com.microsoft.VSCode vscode
Output: (none — exits 0 on success)
Setting http and https is the closest thing to a scriptable "set default browser" — but macOS Ventura+ pops up a confirmation dialog the first time the http handler changes, and the user has to click "Use X". This is enforced by the OS, not by duti. Setting https alone usually works because the prompt only fires for http; setting both is still the safe pattern.
Settings files for bulk setup
Pass any text file as the first positional argument and duti reads it as a list of bindings — one per line. This is the only sane way to ship file-association preferences across machines or as part of a dotfiles repo. Document-type lines have three fields; URL-scheme lines have two. Blank lines and # comments are ignored.
# ~/.config/duti/defaults.duti
# Document types: app_id uti_or_ext role
com.microsoft.VSCode public.plain-text all
com.microsoft.VSCode public.source-code all
com.microsoft.VSCode net.daringfireball.markdown all
com.microsoft.VSCode .yaml all
com.microsoft.VSCode .toml all
com.apple.Preview com.adobe.pdf all
org.videolan.vlc public.movie all
org.videolan.vlc public.audio all
# URL schemes: app_id scheme
org.mozilla.firefox http
org.mozilla.firefox https
com.mimestream.Mimestream mailto
com.ranchero.NetNewsWire-Evergreen feed
Output: (none — file content)
# Apply the whole file in one shot
duti ~/.config/duti/defaults.duti
# Verbose to see each binding being applied
duti -v ~/.config/duti/defaults.duti
Output:
Setting default handler for public.plain-text
app: com.microsoft.VSCode
role: all
Setting default handler for public.source-code
app: com.microsoft.VSCode
role: all
...
A second supported format is a binary or XML property list (plist) — useful when you want comments inside structured config or when you're generating the file from another tool. The shape is a top-level array of dictionaries, each with LSHandlerContentType, LSHandlerRoleAll (or LSHandlerRoleViewer, LSHandlerRoleEditor), and optionally LSHandlerContentTag / LSHandlerContentTagClass for extension-based bindings.
<!-- ~/.config/duti/defaults.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<dict>
<key>LSHandlerContentType</key>
<string>public.plain-text</string>
<key>LSHandlerRoleAll</key>
<string>com.microsoft.VSCode</string>
</dict>
<dict>
<key>LSHandlerContentType</key>
<string>com.adobe.pdf</string>
<key>LSHandlerRoleViewer</key>
<string>com.apple.Preview</string>
</dict>
<dict>
<key>LSHandlerURLScheme</key>
<string>http</string>
<key>LSHandlerRoleAll</key>
<string>org.mozilla.firefox</string>
</dict>
</array>
</plist>
Output: (none — file content)
duti ~/.config/duti/defaults.plist
Output: (none — exits 0 on success)
When changes don't stick — Launch Services cache
Launch Services keeps an on-disk database of which app handles what. duti writes into it, but several caches sit in front: per-user (~/Library/Preferences/com.apple.LaunchServices/*), the in-memory Finder cache, and (since Ventura) an opaque "user override" store that the OS surfaces in System Settings → Default Web Browser / Default Mail Reader. Most "I ran duti and Finder still uses the old app" reports come down to one of these caches being stale.
The first thing to try is just relaunching Finder — that flushes its in-process cache without touching the database.
killall Finder
Output: (none — Finder relaunches automatically)
If a deeper rebuild is needed, lsregister — the private Launch Services CLI hidden inside CoreServices.framework — can scan all installed apps and rebuild the database from scratch. The path is long; alias it.
# Aliased path for legibility
lsregister=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister
# Dump current registry (large; pipe to less)
"$lsregister" -dump | less
# Rebuild from /Applications, /System/Applications, ~/Applications
"$lsregister" -kill -r -domain local -domain system -domain user
killall Finder
Output:
(long progress messages as every .app on disk is rescanned)
The rebuild is heavy — minutes on a system with many apps — and it can disturb other Launch Services state (recent-items, "always open with" entries). Don't run it unless duti bindings actually refuse to apply. On macOS Sequoia and Tahoe (15+ / 26+), a single confirmation popup the first time the http handler changes is normal and not a duti bug — the prompt is enforced by the OS Default-Web-Browser interception, not Launch Services itself.
Common pitfalls
- Bundle ID typos fail silently —
dutireturns exit code 0 even when the bundle ID doesn't resolve to an installed app. Always sanity-check withosascript -e 'id of app "Foo"'first, orduti -vto see the verbose confirmation. -vvs-Vconfusion — lowercase-vis verbose; uppercase-Vprints the version. Reversed from the convention of many CLI tools.- Setting a parent UTI affects every child —
duti -s X public.text allwill rebind.txt,.md,.csv,.log,.py,.sh, and dozens more. Use the specific leaf UTI (or extension) if you only want to rebind one thing. httpchange triggers a Ventura+ confirmation dialog — there's no way to suppress it; the OS forces a one-time user click. Run it interactively the first time, not from an unattended provisioning script.dutidoesn't relaunch Finder for you — the binding lands in the database immediately, but Finder's in-process cache may keep using the old handler until youkillall Finder(or log out and back in)..mdisnet.daringfireball.markdownonly on macOS 12+ — earlier versions usedpublic.plain-textfor.md. If you're scripting across older macOS versions, target both UTIs.- App reinstalls can wipe the binding — drag-and-drop replacing
Foo.appis fine, but uninstalling and reinstalling sometimes resets Launch Services for that bundle ID. Keep your settings file in version control and rerun it after major OS upgrades. - Sandboxed apps may not appear in
-l— if Launch Services hasn't seen an app launch yet (newly installed from a DMG, never opened), it may not register as a candidate handler. Open the app once, then retry.
Real-world recipes
Make VS Code the default for all code and text files
A one-shot script that sets VS Code as the default for every "textual" UTI at once, so freshly-downloaded source files don't open in TextEdit. Idempotent — safe to run repeatedly.
#!/bin/bash
set -euo pipefail
app=com.microsoft.VSCode
for uti in \
public.plain-text \
public.source-code \
public.shell-script \
public.python-script \
public.perl-script \
public.ruby-script \
public.php-script \
public.script \
net.daringfireball.markdown \
public.json \
public.xml \
public.yaml \
public.html
do
duti -s "$app" "$uti" all
done
for ext in .toml .ini .conf .env .gitignore .editorconfig; do
duti -s "$app" "$ext" all
done
killall Finder
echo "Done."
Output:
Done.
Audit the current handler map
Dump every (extension → app) binding for a list of extensions you care about, so you can diff it before/after running a duti script or compare across machines.
exts=(html htm css js json xml md txt log csv yaml toml pdf png jpg mp3 mp4 mkv mov zip tar.gz sh py rb go rs)
for ext in "${exts[@]}"; do
printf '%-10s ' ".$ext"
duti -x "$ext" 2>/dev/null | head -1 || echo "(no handler)"
done
Output:
.html Safari.app
.htm Safari.app
.css VSCode.app
.js VSCode.app
.json VSCode.app
.xml VSCode.app
.md VSCode.app
.txt VSCode.app
.log Console.app
.csv Numbers.app
.yaml VSCode.app
.toml VSCode.app
.pdf Preview.app
.png Preview.app
.jpg Preview.app
.mp3 Music.app
.mp4 VLC.app
.mkv VLC.app
.mov QuickTime Player.app
.zip Archive Utility.app
.tar.gz Archive Utility.app
.sh Terminal.app
.py VSCode.app
.rb VSCode.app
.go VSCode.app
.rs VSCode.app
Restore Apple defaults for one filetype
After experimentation, snap a single UTI back to Apple's stock handler. Useful when you've handed a system to someone else and want to undo your customisations selectively.
# Restore TextEdit as the default plain-text editor
duti -s com.apple.TextEdit public.plain-text all
# Restore Preview for PDFs
duti -s com.apple.Preview com.adobe.pdf all
# Restore Safari for web
duti -s com.apple.Safari public.html all
duti -s com.apple.Safari http
duti -s com.apple.Safari https
killall Finder
Output: (none — exits 0 on success)
Provision a fresh Mac in one command
Drop the settings file into a dotfiles repo and apply it as the last step of a new-machine setup. Pair with Homebrew so duti itself is also installed declaratively.
# In your bootstrap script, after `brew bundle`
duti ~/dotfiles/macos/defaults.duti
killall Finder
echo "File associations applied."
Output:
File associations applied.
Find what bundle ID an open app has
When you want to bind a default to "whatever I just installed" but don't know its bundle ID, look it up from the running process via lsappinfo.
# Front app
lsappinfo info -only bundleID "$(lsappinfo front)"
# All running apps, name + bundle ID
lsappinfo list | awk '/bundleID/||/display name/'
Output:
"LSBundleIdentifier"="com.apple.Safari"
Once you have the bundle ID, pipe it straight into duti:
bid=$(osascript -e 'id of app "Cursor"')
duti -s "$bid" public.plain-text all
duti -s "$bid" public.source-code all
Output: (none — exits 0 on success)
Sources
References consulted while writing this article. Links open in a new tab. Order is curated by relevance — sorting is intentionally disabled (data-no-sort).
| Source | Why cited |
|---|---|
| moretension/duti on GitHub | Canonical source repository and README; confirms current upstream-quiet maintenance status and authoritative install path. |
| duti(1) man page | Authoritative flag list (-h/-V/-v/-d/-l/-s/-x) and the full five-value role table including the rarely-documented none role. |
| duti.org — official documentation | Specification for the plain-text settings-file format (three-column document type vs. two-column URL scheme) and matching plist alternative. |
| duti 1.5.3 on Homebrew (Libraries.io) | Current Homebrew formula version and historical release timeline used in the install section. |
| Controlling Launch Services in macOS Sequoia — Eclectic Light Co. | March-2025 deep dive on the macOS 15 confirmation-dialog behaviour for http-handler changes, informing the URL-scheme and pitfalls sections. |
| lsregister command reference — ss64.com | Full lsregister flag syntax (-kill -r -domain ...) used in the Launch Services cache-rebuild recipe. |
| Using duti to script default applications for Macs — Alan Siu | Classic worked example of applying a bulk settings file at provisioning time, inspiring the "provision a fresh Mac" recipe. |
| Set file associations on macOS with duti — Chuma Umenze | XDG-style ~/.config/duti/defaults.duti organisation pattern used in the settings-file example. |