Switch the macOS keyboard layout automatically per keyboard (Karabiner-Elements)
Published 6 September 2026
- date
- env
- linux N/A macos macOS 26.6.2 (Apple M4 Max), Karabiner-Elements 16.3.0
- tags
If you type on more than one keyboard and they do not share the same physical layout,
macOS gets it wrong roughly half the time. A French Mac keyboard and a French PC
keyboard do not agree on @, <, >, # or the backtick, so every switch between them
means reaching for the input-source menu.
macOS has no per-device input source. Karabiner-Elements does: it sees which keyboard sent a key, so a rule can be conditioned on the device and select the matching input source before the key is delivered.
This HOWTO builds that setup for a three-keyboard desk and then covers the two failure modes that make it stop working — the second one bites after every Karabiner update.
Environment / Notation: the example uses a MacBook Pro (M4) with three keyboards — the built-in keyboard and an Apple Magic Keyboard with Touch ID, both French (Mac), and a Keychron V6 Max (QMK) wired as French-PC. Vendor/product IDs below are the ones this machine reports; read your own with
karabiner_cli --list-connected-devicesinstead of copying them.
Symptom(s)
- You start typing on the external Keychron and get
@where you expected", or a dead backtick — the layout is still the one the previous keyboard needed. - Switching by hand works, but only until you touch another keyboard.
- Plug/unplug detection is useless here: all the keyboards stay connected at once (the Keychron stays paired over Bluetooth), so nothing is “connected” or “disconnected” — the only usable signal is which device just sent a keystroke.
Root cause
macOS keeps one active input source for the whole system. It has no notion of
“this keyboard is French-PC and that one is French-Mac”. Only a driver-level tool that
grabs HID events per device can tell them apart, which is exactly what Karabiner-Elements
does with a device_if condition.
Diagnosis
Confirm Karabiner sees each keyboard and gives you the identifiers to key the rules on:
/Library/Application\ Support/org.pqrs/Karabiner-Elements/bin/karabiner_cli \ --list-connected-devicesEach entry looks like this — note that a QMK/VIA board typically declares both
is_keyboard and is_pointing_device:
{ "device_id": 4315133828, "device_identifiers": { "is_keyboard": true, "is_pointing_device": true, "product_id": 2401, "vendor_id": 13364 }, "product": "Keychron V6 Max", "manufacturer": "Keychron"}Check the exact input-source IDs you want to select (French Mac vs French PC):
defaults read com.apple.HIToolbox AppleEnabledInputSources | grep -i "KeyboardLayout Name""KeyboardLayout Name" = French; # com.apple.keylayout.French (Mac layout)"KeyboardLayout Name" = "French-PC"; # com.apple.keylayout.French-PC (PC layout)Both layouts must be enabled in System Settings → Keyboard → Input Sources first; Karabiner can only select an input source that already exists.
Solution
Everything lives in ~/.config/karabiner/karabiner.json, under
profiles[0] (“Default profile”). Karabiner watches the file and reloads it by itself —
no restart needed. Back it up before editing:
cp ~/.config/karabiner/karabiner.json ~/.config/karabiner/karabiner.json.bak1. Un-ignore the keyboards that also claim to be pointing devices.
This is the step everybody misses. Karabiner ignores a device that declares itself as
both a keyboard and a pointing device unless it has its own entry in
profiles[0].devices with "ignore": false. Without it the rule never fires — and
there is no error anywhere, the layout simply never changes.
The identifiers object must reproduce exactly what
--list-connected-devices printed, booleans included:
"devices": [ { "identifiers": { "is_keyboard": true, "is_pointing_device": true, "product_id": 2401, "vendor_id": 13364 }, "ignore": false }]Add one such block per affected keyboard. Plain keyboards (the built-in one, the Apple Magic Keyboard) need nothing here — they are grabbed by default.
2. Add a rule that selects French-PC for the Keychron V6 Max.
Under profiles[0].complex_modifications.rules. The trick is from: {"any": "key_code"}:
any key coming from that device triggers the switch, and the input_source_unless
condition makes it a no-op once the layout is already correct, so there is no cost on
every keystroke.
{ "description": "Keychron V6 Max -> French PC", "manipulators": [ { "type": "basic", "conditions": [ { "type": "device_if", "identifiers": [{ "vendor_id": 13364, "product_id": 2401 }] }, { "type": "input_source_unless", "input_sources": [{ "input_source_id": "^com\\.apple\\.keylayout\\.French-PC$" }] } ], "from": { "any": "key_code" }, "to": [ { "select_input_source": { "input_source_id": "^com\\.apple\\.keylayout\\.French-PC$" } } ] } ]}3. Add the mirror rule for the Mac-layout keyboards.
One rule can list several devices in identifiers; is_built_in_keyboard covers the
laptop’s own keyboard without hard-coding an ID.
{ "description": "Apple + built-in -> French Mac", "manipulators": [ { "type": "basic", "conditions": [ { "type": "device_if", "identifiers": [ { "vendor_id": 76, "product_id": 671 }, { "is_built_in_keyboard": true } ] }, { "type": "input_source_unless", "input_sources": [{ "input_source_id": "^com\\.apple\\.keylayout\\.French$" }] } ], "from": { "any": "key_code" }, "to": [ { "select_input_source": { "input_source_id": "^com\\.apple\\.keylayout\\.French$" } } ] } ]}4. Validate the JSON and confirm the devices are grabbed.
/Library/Application\ Support/org.pqrs/Karabiner-Elements/bin/karabiner_cli \ --lint-complex-modifications ~/.config/karabiner/karabiner.json
grep -iE 'grabbed' /var/log/karabiner/core_service.log | tail -5You want one line per keyboard:
[info] [core_service (daemon)] Keychron V6 Max (device_id:xxxxxxxxx) hid device events monitor is started (grabbed).[info] [core_service (daemon)] Apple Internal Keyboard / Trackpad (device_id:xxxxxxxxx) hid device events monitor is started (grabbed).If a keyboard only shows caps lock is found on … and never a (grabbed) line, the
device is still ignored — go back to step 1. That is a missing devices entry, not
a broken rule.
5. Test. Type one key on each keyboard and watch the input-source menu bar item
change. The very first keystroke after switching keyboards is the one that flips the
layout, so it may still be interpreted with the old layout — press one throwaway key
(e.g. shift) when changing keyboards.
When it stops working — the launchd trap after a Karabiner update
Karabiner runs as two halves: a root daemon (Karabiner-Core-Service) that grabs the
HID devices, and a user agent (Karabiner-Console-User-Server) that actually applies
your complex_modifications. If the user agent dies, the daemon keeps running and
Karabiner looks alive — but no rule is applied and the layout stays frozen.
This happened on two consecutive updates: 16.2.0 renamed the agent
(org.pqrs.service.agent.karabiner_console_user_server →
org.pqrs.service.agent.Karabiner-Console-User-Server) and the new one never started;
16.3.0 started it once and it exited. Suspect this first after every Karabiner update,
before touching your configuration.
Diagnose it in three commands:
# 1. Is the user agent running? You should see BOTH a root Karabiner-Core-Service# and a Karabiner-Console-User-Server owned by your user.ps aux | grep -i karabiner | grep -v grep
# 2. What does launchd think?launchctl print gui/$UID/org.pqrs.service.agent.Karabiner-Console-User-Server \ | grep -E 'state|runs|last exit'
# 3. The user-side log simply stops at the date of the update.tail -5 ~/.local/share/karabiner/log/console_user_server.logThe signature of the fault:
state = not runningruns = 1job state = exitedand karabiner_cli --list-connected-devices failing with error:asio.system:13.
Fix — one command:
launchctl kickstart -k gui/$UID/org.pqrs.service.agent.Karabiner-Console-User-ServerThe job is KeepAlive, so once it is up it stays up. Verify:
ps aux | grep -i "Console-User-Server" | grep -v grepgrep -iE 'grabbed' /var/log/karabiner/core_service.log | tail -5Fresh (grabbed) lines with the current timestamp mean the daemon has re-grabbed the
keyboards and the rules are live again. Type a key on each keyboard to confirm.
If the agent still refuses to stay alive, re-grant Karabiner its permissions in System Settings → Privacy & Security → Input Monitoring and Accessibility — a major macOS or Karabiner upgrade can silently drop them — then reboot.
Notes and good practices
devicesvscomplex_modificationsis the whole debugging split. No(grabbed)line → it is thedevicesentry. A(grabbed)line but no switch → it is the rule, or the user agent is dead.- The same board can enumerate differently over USB and Bluetooth. Check both; if the
IDs differ, list both in the rule’s
identifiersarray. - Keep the
input_source_unlesscondition. Without it Karabiner re-selects the input source on every keystroke, which is wasteful and can make some apps flicker. - Do not hand-edit while the Karabiner-Elements settings window is open — it may
rewrite the file and drop your changes. Close it first, and keep the
.bak. - Reverting is just restoring the backup, or deleting the two rules and the
devicesentries; nothing outside~/.config/karabiner/karabiner.jsonis modified. - Karabiner writes automatic backups to
~/.config/karabiner/automatic_backups/— a quick way back if you break the JSON.
Quick reference
CLI="/Library/Application Support/org.pqrs/Karabiner-Elements/bin/karabiner_cli"
# device identifiers to key the rules on"$CLI" --list-connected-devices
# validate the config after editing ~/.config/karabiner/karabiner.json"$CLI" --lint-complex-modifications ~/.config/karabiner/karabiner.json
# are the keyboards actually grabbed?grep -iE 'grabbed' /var/log/karabiner/core_service.log | tail -5
# --- auto-switch stopped working (typically after a Karabiner update) ---ps aux | grep -i karabiner | grep -v greplaunchctl print gui/$UID/org.pqrs.service.agent.Karabiner-Console-User-Server \ | grep -E 'state|runs|last exit'launchctl kickstart -k gui/$UID/org.pqrs.service.agent.Karabiner-Console-User-Server