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

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-devices instead 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:

Terminal window
/Library/Application\ Support/org.pqrs/Karabiner-Elements/bin/karabiner_cli \
--list-connected-devices

Each 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):

Terminal window
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:

Terminal window
cp ~/.config/karabiner/karabiner.json ~/.config/karabiner/karabiner.json.bak

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

Terminal window
/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 -5

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

Terminal window
# 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.log

The signature of the fault:

state = not running
runs = 1
job state = exited

and karabiner_cli --list-connected-devices failing with error:asio.system:13.

Fix — one command:

Terminal window
launchctl kickstart -k gui/$UID/org.pqrs.service.agent.Karabiner-Console-User-Server

The job is KeepAlive, so once it is up it stays up. Verify:

Terminal window
ps aux | grep -i "Console-User-Server" | grep -v grep
grep -iE 'grabbed' /var/log/karabiner/core_service.log | tail -5

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

  • devices vs complex_modifications is the whole debugging split. No (grabbed) line → it is the devices entry. 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 identifiers array.
  • Keep the input_source_unless condition. 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 devices entries; nothing outside ~/.config/karabiner/karabiner.json is modified.
  • Karabiner writes automatic backups to ~/.config/karabiner/automatic_backups/ — a quick way back if you break the JSON.

Quick reference

Terminal window
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 grep
launchctl 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