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).

bash
brew install duti

# Verify
which duti
duti -V

Output:

text
/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).

bash
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.

bash
# 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

OptionMeaning
-s BUNDLE TYPE ROLESet the default handler — bundle ID + UTI/extension + role
-d UTIDisplay the current default handler for a UTI
-l UTIList all registered handlers for a UTI (not just the default)
-x EXTShow app name, path, and bundle ID of the default handler for a file extension
-vVerbose — print what duti is sending to Launch Services
-VPrint version and exit (capital V — -v is verbose)
-hPrint 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.

bash
# 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:

text
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.

RoleWhat it meansTypical use
allApp handles every role for this UTIThe 99% case — "open with X for this filetype"
viewerApp reads and displays onlyA different editor handles editing
editorApp reads and writes (implies viewer)Pairing a viewer with an editor
shellApp can execute the itemRare; mainly .command / shell-script types
noneApp can't open it but provides an iconAlmost never used outside theming
bash
# Find the UTI for an extension — Launch Services answers via duti -x
duti -x md

Output:

text
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:

bash
# 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:

text
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.

bash
# 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:

text
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."

bash
# 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.

bash
duti -d public.html
duti -d public.plain-text
duti -d com.adobe.pdf

Output:

text
com.apple.Safari
com.microsoft.VSCode
com.apple.Preview
bash
# Which apps *could* open HTML?
duti -l public.html

Output:

text
com.apple.Safari
com.google.Chrome
org.mozilla.firefox
com.microsoft.VSCode
com.apple.TextEdit
bash
# Friendly view, keyed by extension instead of UTI
duti -x html
duti -x pdf
duti -x sh

Output:

text
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."

bash
# 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.

bash
# ~/.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)

bash
# 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:

text
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.

xml
<!-- ~/.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)

bash
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.

bash
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.

bash
# 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:

text
(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

  1. Bundle ID typos fail silentlyduti returns exit code 0 even when the bundle ID doesn't resolve to an installed app. Always sanity-check with osascript -e 'id of app "Foo"' first, or duti -v to see the verbose confirmation.
  2. -v vs -V confusion — lowercase -v is verbose; uppercase -V prints the version. Reversed from the convention of many CLI tools.
  3. Setting a parent UTI affects every childduti -s X public.text all will 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.
  4. http change 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.
  5. duti doesn'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 you killall Finder (or log out and back in).
  6. .md is net.daringfireball.markdown only on macOS 12+ — earlier versions used public.plain-text for .md. If you're scripting across older macOS versions, target both UTIs.
  7. App reinstalls can wipe the binding — drag-and-drop replacing Foo.app is 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.
  8. 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.

bash
#!/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:

text
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.

bash
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:

text
.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.

bash
# 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.

bash
# In your bootstrap script, after `brew bundle`
duti ~/dotfiles/macos/defaults.duti
killall Finder
echo "File associations applied."

Output:

text
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.

bash
# Front app
lsappinfo info -only bundleID "$(lsappinfo front)"

# All running apps, name + bundle ID
lsappinfo list | awk '/bundleID/||/display name/'

Output:

text
"LSBundleIdentifier"="com.apple.Safari"

Once you have the bundle ID, pipe it straight into duti:

bash
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).

SourceWhy cited
moretension/duti on GitHubCanonical source repository and README; confirms current upstream-quiet maintenance status and authoritative install path.
duti(1) man pageAuthoritative 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 documentationSpecification 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.comFull lsregister flag syntax (-kill -r -domain ...) used in the Launch Services cache-rebuild recipe.
Using duti to script default applications for Macs — Alan SiuClassic 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 UmenzeXDG-style ~/.config/duti/defaults.duti organisation pattern used in the settings-file example.