The xonsh cheat sheet

xonsh is a shell like bash, zsh, or fish. It is unique in the sense that it’s written in Python and allows you to write Python right there at your shell prompt. There’s a catch, but personally I find that it’s totally worth it. But it can be confusing switching from other shells, so here’s a quick list of things I myself keep forgetting.

This post is not meant as an intro to xonsh as it focuses on things that might surprise you, i.e. “gotchas”. Therefore, it’s not a great advertisement for why this shell is so nice to use. 1 I’ll have to write a separate post for that.

A small note on styling in the examples below: xonsh by default uses the @ sign for its prompt, because it vaguely looks like snake and/or shell (xonsh is pronounced "conch"). Even though I personally use Starship as my prompt, I kept the shell-like character here.

Two modes

xonsh works by automatically recognizing whether the command you typed is a shell command or Python code. It doesn’t always do a perfect job.

Shell command (xonsh docs call this the “subprocess mode"):

@ ls -l
total 4776
-rw-r--r--    1 ambv  staff   29215 Aug 14 12:02 aclocal.m4
drwxr-xr-x    6 ambv  staff     204 Aug 14 12:02 Android
-rw-r--r--    1 ambv  staff    9165 Aug 15 12:17 confdefs.h
...

The Python mode:

@ ls = 44
@ l = 2
@ ls -l
42

Surprise, xonsh thinks this is Python code because it ends with a colon ( is the cursor here):

@ ./python.exe -m test test_pyrepl -R:
    

Solution: wrap the argument in a string:

@ ./python.exe -m test test_pyrepl "-R:"
Using random seed: 349986338
0:00:00 load avg: 2.17 Run 1 test sequentially in a single process
0:00:00 load avg: 2.17 [1/1] test_pyrepl
...

Python variables vs. environment variables

As you saw in the ls -l example, you can declare regular Python variables and then use them in other Python code. But there’s also environment variables:

@ $HOME
'/Users/ambv'
@ echo $HOME/.config
/Users/ambv/.config

You can put them inside strings (more on strings below):

@ echo "$HOME/.config"

You can access the entire environment dictionary using ${...}:

@ "HOME" in ${...}
True
@ ${...}.get("USER")
'ambv'

If you replace the ... ellipsis token with a Python expression returning a string, you can refer to arbitrary env vars:

@ ${"USER"}
'ambv'
@ env_var = "user"
@ ${env_var.upper()}
'ambv'

This is also how you refer to environment variables in strings when directly followed by alphanumeric characters:

@ echo "${'HOME'}yeah"
/Users/ambvyeah

Env variables can be lists, which is gorgeous:

@ $PATH
EnvPath(
['/Users/ambv/.dot_files/bin',
 '/Users/ambv/.poetry/bin',
 '/Users/ambv/.cargo/bin',
 '/Users/ambv/.local/bin',
 '/Library/TeX/texbin',
 '/opt/homebrew/bin',
 '/Users/ambv/opt/anaconda3/condabin',
 '/usr/local/bin',
 '/usr/bin',
 '/bin',
 '/usr/sbin',
 '/sbin']
)
@ $PATH.insert(0, "/Library/Developer/CommandLineTools/usr/bin/")
@ $PATH.remove('/opt/homebrew/bin')
@ $PATH
EnvPath(
['/Library/Developer/CommandLineTools/usr/bin/',
 '/Users/ambv/.dot_files/bin',
 '/Users/ambv/.poetry/bin',
 '/Users/ambv/.cargo/bin',
 '/Users/ambv/.local/bin',
 '/Library/TeX/texbin',
 '/Users/ambv/opt/anaconda3/condabin',
 '/usr/local/bin',
 '/usr/bin',
 '/bin',
 '/usr/sbin',
 '/sbin']
)

But they can be any type, which might bite you if you’re not careful:

@ $A=1
@ type($A)
int
@ $A+"s"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'str'

When you find shell commands online with env vars prepended to the command, you need to remember to add the $:

@ NO_COLOR=1 ./python.exe
xonsh: subprocess mode: command not found: 'NO_COLOR=1'
@ $NO_COLOR=1 ./python.exe
Python 3.15.0a0 (heads/main:f0a3c6ebc9b, Aug 15 2025, 12:17:44) [Clang 17.0.0 (clang-1700.0.13.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

Strings in xonsh are Python strings, and then some

Those strings that you’re using, including in shell mode, are Python strings. This is super powerful, but sometimes weird.

You can use f-strings:

@ which_test = "pyrepl"
@ ./python.exe -m test f"test_{which_test}" "-R:"
Using random seed: 1789368327
0:00:00 load avg: 2.35 Run 1 test sequentially in a single process
0:00:00 load avg: 2.35 [1/1] test_pyrepl
...

In fact, often you need to explicitly use strings where you wouldn’t in Bash:

@ rg from\ _pyrepl\\.\\S+\ import
rg: regex parse error:
    (?:from\)
    ^
error: unclosed group
@ rg "from _pyrepl\.\S+ import"
<unknown>:1: SyntaxWarning: invalid escape sequence '\.'
Lib/asyncio/__main__.py
16:from _pyrepl.console import InteractiveColoredConsole
112:                from _pyrepl.simple_interact import (
...

You might have noticed the SyntaxWarning above due to escapes being treated as regular Python escapes. You need to explicitly use raw strings when writing regexes:

@ rg r"from _pyrepl\.\S+ import"
Lib/asyncio/__main__.py
16:from _pyrepl.console import InteractiveColoredConsole
112:                from _pyrepl.simple_interact import (
...

But you can use ! after a command to make everything else you type be treated as a single raw string argument:

@ rg! from _pyrepl\.\S+ import
Lib/asyncio/__main__.py
16:from _pyrepl.console import InteractiveColoredConsole
112:                from _pyrepl.simple_interact import (
...

One thing that also trips me up sometimes is that quotes in the middle of an argument are removed in traditional shells but not in xonsh:

@ python -c "import sys; print(sys.argv)" --arg="look at this string!"
['-c', '--arg="look at this string!"']

Most command-line tools deal with this just fine, but in case they don’t, just wrap the entire argument in a string:

@ ./python.exe -c "import sys; print(sys.argv)" "--arg=look at this string!"
['-c', '--arg=look at this string!']

Globbing strings

You can use backticks as magical regex globbing strings:

@ `string/.*\.py`
['string/__init__.py', 'string/templatelib.py']

Doesn’t seem like there’s an equivalent of **/ 2, but you can use g-backticks for traditional glob syntax globbing strings:

@ g`**/utils.py`
['_pyrepl/utils.py',
 'email/utils.py',
 'test/libregrtest/utils.py',
 'test/test_ast/utils.py',
 'test/test_asyncio/utils.py',
 'test/test_interpreters/utils.py']

You can also use p-strings to get Path objects:

@ p"_pyrepl/utils.py".is_file()
True

There’s also p-backticks and pg-backticks for lists of Path objects:

@ pg`**/utils.py`
[PosixPath('_pyrepl/utils.py'),
 PosixPath('email/utils.py'),
 PosixPath('test/libregrtest/utils.py'),
 PosixPath('test/test_ast/utils.py'),
 PosixPath('test/test_asyncio/utils.py'),
 PosixPath('test/test_interpreters/utils.py')]

Value injection

Since we’re marrying Python code execution and shell pipelines in one language, you can do pretty fun things like having Python for-loops over shell commands. But then very quickly a question appears: how to refer to the Python loop variable from a shell command line? There’s a few ways.

Strings:

@ for i in range(3):
      echo f"{i}"
0
1
2

Python expression injection:

@ for i in range(3):
      echo @(i * 2)
0
2
4

Unlike ${...}, @(...) is not evaluated inside strings.

What if I wanted to do the opposite, i.e. inject a shell command’s result into a Python expression? You use $():

@ for f in $(find Lib -type dir -maxdepth 1).splitlines():
      if (pf"{f}" / "__main__.py").is_file():
          echo @(f)
Lib/_pyrepl
Lib/asyncio
Lib/ensurepip
Lib/idlelib
Lib/json
Lib/profile
Lib/sqlite3
Lib/sysconfig
Lib/test
Lib/tkinter
Lib/turtledemo
Lib/unittest
Lib/venv
Lib/zipfile

Unlike ${...}, $(...) is not evaluated inside strings.

But what if you want to inject a shell command’s result into another shell command? Well, if $(pwd) returns the output of pwd as a Python string, you should be able to use it within shell commands, too, right? Yeah, but there’s a weird gotcha:

@ echo yeah $(pwd) that works
yeah /Users/ambv/Python/project that works
@ echo this does$(pwd) not
  

As long as there’s a space between $() and other arguments, everything’s fine. But if there’s no space, xonsh thinks we’re writing Python now. This honestly looks like a bug to me, I need to remember to report it. After all, @() works fine in this context without a space:

@ echo this does@(1+2) work
this does3 work

So in the mean time, you can work around it using @($(pwd)) or its shortcut @$(pwd):

@ echo this works@$(pwd) fine
this works/Users/ambv/Python/project fine

Redirections

This is something that xonsh does right:

@ wc -l <Lib/configparser.py
​    1414
@ wc -l <Lib/configparser.py out>/tmp/output
@ cat /tmp/output
​    1414
@ wc -lx <Lib/configparser.py err>/tmp/error
@ cat /tmp/error
wc: illegal option -- x
usage: wc [-Lclmw] [file ...]
@ wc -lx <Lib/configparser.py err>out
wc: illegal option -- x
usage: wc [-Lclmw] [file ...]
@ TEMP = p'/tmp/'
@ wc -l <Lib/configparser.py all>>@(TEMP / 'all')
@ wc -lx <Lib/configparser.py all>>@($TMPDIR)/all
@ cat /tmp/all
​    1414
wc: illegal option -- x
usage: wc [-Lclmw] [file ...]

There is no equivalent of heredocs (<<< and <<EOF/EOF) and temporary file descriptors (like diff <(ls /bin) <(ls /usr/bin)) returning magic paths.

Sourcing Bash into xonsh

That’s a surprising but very useful capability, since few external programs that modify your environment actually know or care about xonsh. So you can do:

@ source-bash ~/Python/project/.venv/bin/activate
(.venv)@ 

It doesn’t work entirely seamlessly (for instance, in the example above you can’t effectively deactivate later), but it’s good enough to not have to rewrite third-party Bash scripts to get some env vars set up. For virtualenvs, you want to use vox anyway.


  1. Long story short: no more insane Bash string manipulation, function creation and calling, unreadable if statements and loops, no more $IFS, no more weird error handling. Ability to just type Python code in the shell for little calculations and data processing. Unprecedented flexibility in customization with a sensible plugin system. You can change xonsh’s behavior while it’s running. 

  2. Correct me if I’m wrong! 

#Programming