Q is a minimal, dependency-free programming language and compiler targeting x86-64 and arm64 with ultra-fast builds and tiny binaries.
- High performance (comparable to C and Go)
- Fast compilation (5-10x faster than most)
- Tiny executables ("Hello World" is 0.6 KiB)
- Static analysis (no need for external linters)
- Pointer safety (pointers cannot be nil)
- Resource safety (use-after-free is a compile error)
- Multiple platforms (Linux, Mac and Windows)
- Zero dependencies (no llvm, no libc)
Warning
Q is still in development and not ready for production yet.
Please read the comment on the status of the project.
Feel free to contact me if you are interested in helping out.
Build from source:
git clone https://git.urbach.dev/cli/q
cd q
go build
Install via symlink:
ln -s $PWD/q ~/.local/bin/q
Run:
q examples/hello
Build:
q build examples/hello
Cross-compile:
q build examples/hello --os [linux|mac|windows] --arch [x86|arm]
- 2025-09-09: Type casts.
- 2025-09-08: Function pointers.
- 2025-09-07: Pointer safety.
- 2025-09-03: Error handling.
- 2025-08-31: Constant folding.
- 2025-08-25: Resource safety.
- 2025-08-23: Function overloading.
- 2025-08-18: Slices for strings.
- 2025-08-17: Struct allocation by value/reference.
- 2025-08-16: Multiple return values.
- 2025-08-15: Data structures.
- 2025-08-14: Memory load and store instructions.
- 2025-08-13: Naive memory allocations.
- 2025-08-12: Support for Windows on arm64.
- 2025-08-11: Support for Mac on arm64.
The syntax is still highly unstable because I'm focusing my work on the correct machine code generation for all platforms and architectures. However, you can take a look at the examples and the tests to get a perspective on the current status.
A few selected examples:
Advanced examples using unstable APIs:
The following is a cheat sheet documenting the syntax.
I need to... | API stability | |
---|---|---|
Define a new variable | x := 1 |
✔️ Stable |
Reassign an existing variable | x = 2 |
✔️ Stable |
Define a function | main() {} |
✔️ Stable |
Define a struct | Point {} |
✔️ Stable |
Define input and output types | f(a int) -> (b int) {} |
✔️ Stable |
Define same function for other types | f(_ string) {} f(_ int) {} |
🚧 Experimental |
Instantiate a struct | Point{x: 1, y: 2} |
✔️ Stable |
Instantiate a type on the heap | new(Point) |
🚧 Experimental |
Delete a type from the heap | delete(p) |
🚧 Experimental |
Access struct fields | p.x |
✔️ Stable |
Dereference a pointer | [ptr] |
✔️ Stable |
Index a pointer | ptr[0] |
✔️ Stable |
Slice a string | "Hello"[1..3] |
✔️ Stable |
Slice a string from index | "Hello"[1..] |
✔️ Stable |
Slice a string until index | "Hello"[..3] |
✔️ Stable |
Return multiple values | return 1, 2 |
✔️ Stable |
Loop | loop {} |
✔️ Stable |
Loop 10 times | loop 0..10 {} |
✔️ Stable |
Loop 10 times with a variable | loop i := 0..10 {} |
✔️ Stable |
Branch | if {} else {} |
✔️ Stable |
Branch multiple times | switch { cond {} _ {} } |
✔️ Stable |
Define a constant | const { x = 42 } |
✔️ Stable |
Declare an external function | extern { g { f() } } |
✔️ Stable |
Allocate memory | mem.alloc(4096) |
✔️ Stable |
Free memory | mem.free(buffer) |
🚧 Experimental |
Output a string | io.write("Hello\n") |
✔️ Stable |
Output an integer | io.write(42) |
✔️ Stable |
Cast a type | x as byte |
🚧 Experimental |
Mark a type as a resource | ! |
🚧 Experimental |
Mark a parameter as unused | _ |
✔️ Stable |
Warning
This feature is very new and still undergoing refinement. For more information, refer to linear types or borrowing in other languages.
Resources are shared objects such as files, memory or network sockets. The use of resource types prevents the following problems:
- Resource leaks (forgetting to free a resource)
- Use-after-free (using a resource after it was freed)
- Double-free (freeing a resource twice)
Any type, even integers, can be turned into a resource by prefixing the type with !
. For example, consider these minimal functions:
alloc() -> !int { return 1 }
use(_ int) {}
free(_ !int) {}
With this, forgetting to call free
becomes impossible:
x := alloc()
use(x)
x := alloc()
┬
╰─ Resource of type '!int' not consumed
Attempting a use-after-free is also rejected:
x := alloc()
free(x)
use(x)
use(x)
┬
╰─ Unknown identifier 'x'
Likewise, a double-free is disallowed:
x := alloc()
free(x)
free(x)
free(x)
free(x)
┬
╰─ Unknown identifier 'x'
The compiler only accepts the correct usage order:
x := alloc()
use(x)
free(x)
The !
prefix marks a type to be consumed exactly once. It has no runtime overhead. When a !int
is passed to another !int
, the original variable is invalidated in subsequent code. As an exception, converting !int
to int
bypasses this rule, allowing multiple uses.
The standard library currently makes use of this feature in two packages:
fs.open
must be followed byfs.close
mem.alloc
must be followed bymem.free
Any function can define an error
type return value at the end:
a, b, err := canFail()
An error value protects all the return values to the left of it.
The protected values a
and b
can not be accessed without checking err
first.
Additionally, error variables like err
are invalidated after the branch that checked them.
a, b, err := canFail()
// a and b are inaccessible
if err != 0 {
return
}
// a and b are accessible
// err is no longer defined
The error
type is currently defined to be an integer. This will most likely change in a future version.
The source code structure uses a flat layout without nesting:
- arm - arm64 architecture
- asm - Generic assembler
- ast - Abstract syntax tree
- cli - Command line interface
- codegen - SSA to assembly code generation
- compiler - Compiler frontend
- config - Build configuration
- core - Defines
Function
and compiles tokens to SSA - cpu - Types to represent a generic CPU
- data - Data container that can re-use existing data
- dll - DLL support for Windows systems
- elf - ELF format for Linux executables
- errors - Error handling that reports lines and columns
- exe - Generic executable format to calculate section offsets
- expression - Expression parser generating trees
- fold - Constant folding
- fs - File system access
- global - Global variables like the working directory
- linker - Frontend for generating executable files
- macho - Mach-O format for Mac executables
- memfile - Memory backed file descriptors
- pe - PE format for Windows executables
- scanner - Scanner that parses top-level instructions
- set - Generic set implementation
- ssa - Static single assignment types
- token - Tokenizer
- types - Type system
- verbose - Verbose output
- x86 - x86-64 architecture
The typical flow for a build command is the following:
There is also an interactive dependency graph and a flame graph (via gopkgview and pprof).
arm64 | x86-64 | |
---|---|---|
🐧 Linux | ✔️ | ✔️ |
🍏 Mac | ✔️ | ✔️ |
🪟 Windows | ✔️ | ✔️ |
arm64 | x86-64 | |
---|---|---|
🐧 Linux | 0.6 KiB | 0.6 KiB |
🍏 Mac | 16.3 KiB | 4.2 KiB |
🪟 Windows | 1.7 KiB | 1.7 KiB |
Recursive Fibonacci benchmark (n = 35
):
arm64 | x86-64 | |
---|---|---|
C (-O3, gcc 15) | 41.4 ms ± 1.4 ms | 24.5 ms ± 3.2 ms |
Q (2025-08-20) | 54.2 ms ± 1.6 ms | 34.8 ms ± 2.3 ms |
Go (1.25, new GC) | 57.7 ms ± 1.4 ms | 37.9 ms ± 6.9 ms |
C (-O0, gcc 15) | 66.4 ms ± 1.5 ms | 47.8 ms ± 4.4 ms |
While the current results lag behind optimized C, this is an expected stage of development. I am actively working to improve the compiler's code generation to a level that can rival optimized C, and I expect a significant performance boost as this work progresses.
The table below shows latency numbers on a 2015 Macbook:
x86-64 | |
---|---|
q | 81.0 ms ± 1.0 ms |
go @1.25 | 364.5 ms ± 3.3 ms |
clang @17.0.0 | 395.9 ms ± 3.3 ms |
rustc @1.89.0 | 639.9 ms ± 3.1 ms |
v @0.4.11 | 1117.0 ms ± 3.0 ms |
zig @0.15.1 | 1315.0 ms ± 12.0 ms |
odin @accdd7c2a | 1748.0 ms ± 8.0 ms |
Latency measures the time it takes a compiler to create an executable file with a nearly empty main function. It should not be confused with throughput.
Advanced benchmarks for throughput have not been conducted yet, but the following table shows timings in an extremely simplified test parsing 1000 Fibonacci functions named fib0
to fib999
:
x86-64 | |
---|---|
q | 96.0 ms ± 1.5 ms |
go @1.25 | 372.2 ms ± 5.3 ms |
clang @17.0.0 | 550.8 ms ± 3.8 ms |
rustc @1.89.0 | 1101.0 ms ± 4.0 ms |
v @0.4.11 | 1256.0 ms ± 4.0 ms |
zig @0.15.1 | 1407.0 ms ± 12.0 ms |
odin @accdd7c2a | 1770.0 ms ± 7.0 ms |
The backend is built on a Static Single Assignment (SSA) intermediate representation, the same approach used by mature compilers such as gcc
, go
, and llvm
. SSA greatly simplifies the implementation of common optimization passes, allowing the compiler to produce relatively high-quality assembly code despite the project's early stage of development.
Yes. The compiler can build an entire script within a few microseconds.
#!/usr/bin/env q
import io
main() {
io.write("Hello\n")
}
You need to create a file with the contents above and add execution permissions via chmod +x
. Now you can run the script without an explicit compiler build. The generated machine code runs directly from RAM if the OS supports it.
No, the current implementation is only temporary and it needs to be replaced with a faster one once the required language features have been implemented.
PIE: All executables are built as position independent executables supporting a dynamic base address.
W^X: All memory pages are loaded with either execute or write permissions but never with both. Constant data is read-only.
Read | Execute | Write | |
---|---|---|---|
Code | ✔️ | ✔️ | ❌ |
Data | ✔️ | ❌ | ❌ |
Neovim: Planned.
VS Code: Clone the vscode-q repository into your extensions folder (it enables syntax highlighting).
Because of readability and great tools for concurrency. The implementation will be replaced by a self-hosted compiler in the future.
Testing infrastructure and support for existing and new architectures.
/ˈkjuː/ just like Q
in the English alphabet.
Not at the moment. This project is currently part of a solo evaluation. Contributions will be accepted starting 2025-12-01.
# Run all tests:
go run gotest.tools/gotestsum@latest
# Generate coverage:
go test -coverpkg=./... -coverprofile=cover.out ./...
# View coverage:
go tool cover -func cover.out
go tool cover -html cover.out
# Run compiler benchmarks:
go test ./tests -run '^$' -bench . -benchmem
# Run compiler benchmarks in single-threaded mode:
GOMAXPROCS=1 go test ./tests -run '^$' -bench . -benchmem
# Generate profiling data:
go test ./tests -run '^$' -bench . -benchmem -cpuprofile cpu.out -memprofile mem.out
# View profiling data:
go tool pprof --nodefraction=0.1 -http=:8080 ./cpu.out
go tool pprof --nodefraction=0.1 -http=:8080 ./mem.out
The compiler has verbose output showing how it understands the program code using --ssa
and --asm
flags.
If that doesn't reveal any bugs, you can also use the excellent blinkenlights from Justine Tunney to step through the x86-64 executables one instruction at a time.
#q on irc.urbach.dev.
In alphabetical order:
- Anto "xplshn" | feedback on public compiler interfaces
- Bjorn De Meyer | feedback on PL design
- Furkan | first one to buy me a coffee
- James Mills | first one to contact me about the project
- Laurent Demailly | indispensable help with Mac debugging
- Max van IJsselmuiden | feedback and Mac debugging
- Nikita Proskourine | first monthly supporter on GitHub
- Tibor Halter | detailed feedback and bug reporting
- my wife :) | providing syntax feedback as a non-programmer
Please see the license documentation.
© 2025 Eduard Urbach