V is a statically typed, compiled language designed around three ideas: extreme simplicity, sub-second compilation, and memory safety without a borrow checker. Created by Alexander Medvednikov and first announced publicly in 2019, V generated enormous enthusiasm — and equally enormous controversy. The original claims were breathtaking: no null, no global variables, autofree memory management, a massive standard library, hot code reloading, a built-in ORM, cross-compilation to every major platform, and compilation speeds of 1.2 million lines per second. Some of those claims were real. Some were aspirational. Some were frankly not true at the time.
Seven years later, V is a real language with real code you can ship. It has matured substantially. But the hype has also permanently shaped how people approach it — with either irrational enthusiasm or reflexive dismissal. This post tries to cut through both and give you an accurate, technical picture of what V actually is in 2026, what code written in V looks like, where it genuinely excels, and where you should choose something else.
1. What V Is — And What It Was Promised to Be
The Origin and Design Goals
V was created by Alexander Medvednikov, a developer who wanted a language with the simplicity of Go, the performance of C, memory safety without the learning curve of Rust’s borrow checker, and compile times so fast that the feedback loop felt instantaneous.
The stated design goals:
- Simplicity: The entire language specification should fit in a single page. There are no generics footguns, no template metaprogramming, no implicit conversions.
- Fast compilation: Sub-second builds for most projects. No incremental compilation required — just compile fast from scratch every time.
- Memory safety: No null pointers, no undefined behavior, no buffer overflows in safe V code — without requiring the developer to annotate lifetimes.
- No garbage collector by default: The autofree engine handles memory automatically at compile time, not runtime.
- Small runtime: V programs can be compiled to tiny binaries suitable for embedded systems.
The current stable release as of early 2026 is V 0.4.x. V has not yet declared a 1.0 release, and that versioning choice matters for how you evaluate it.
The Controversy
When V launched in 2019, it raised significant funding through an open-source sponsorship and generated hundreds of thousands of GitHub stars. The problem: the language didn’t actually implement most of what was claimed. The compiler produced C code (V transpiles to C, it doesn’t have its own backend by default), autofree didn’t work for most real programs, the standard library was skeletal, and several features listed as complete on the website were aspirational.
The criticism from the programming language community was sharp. A detailed analysis by a developer under the handle “Vlang Issues” documented dozens of examples where features claimed to be working were broken or missing. Medvednikov acknowledged some issues and disputed others. The language was open-source throughout, so the actual state of the code was always inspectable — but the marketing had run significantly ahead of reality.
Where does V stand today vs the original claims?
| Original Claim |
Reality in 2026 |
| Sub-second compilation |
True and genuine — V compiles fast |
| No null, option types |
Implemented and working well |
| Autofree engine |
Exists, works for common cases, still has edge cases in complex programs |
| Built-in ORM |
Implemented and functional |
| Hot code reloading |
Works for specific use cases |
| Massive standard library |
Decent but smaller than Go’s |
| Cross-compilation |
Works, though not as seamlessly as claimed |
| JavaScript backend |
Exists, usable for simple programs |
| 1.2M lines/sec compilation |
Roughly accurate for the C backend path |
| Generics |
Added in 0.4.x, functional |
| Windows support |
Present but historically weaker than Linux/macOS |
The honest summary: V is a real language with real features that has been converging on its promises since 2020. It’s not vaporware. It’s also not as polished as Go or Rust. The ecosystem is small, the tooling is good but not great, and you’ll hit rough edges if you build something nontrivial. With eyes open, V can be genuinely useful for specific use cases.
Installing V
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# Clone and build from source (recommended)
git clone https://github.com/vlang/v
cd v
make
# Add to PATH
sudo ./v symlink
# Verify
v version
# V 0.4.x ...
# Or use the installer on supported platforms
# Linux/macOS:
# wget https://github.com/vlang/v/releases/latest/download/v_linux.zip
|
2. Core Language — Syntax and Fundamentals
V’s syntax is deliberately Go-like. If you know Go, you’ll read V immediately. If you know C or Rust, it takes about an hour to feel at home.
Hello World and Basic Types
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
fn main() {
println('Hello, world!')
// Basic types
x := 42 // int, inferred
y := 3.14 // f64, inferred
name := 'Alice' // string
active := true // bool
// Explicit types
count: int = 100
ratio: f32 = 0.5
big: i64 = 9_000_000_000
println('${name} has ${count} items, active: ${active}')
}
|
Immutability by Default
One of V’s most important design decisions: variables are immutable by default. You must explicitly mark them mutable with mut.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
fn main() {
// Immutable by default
message := 'hello'
// message = 'world' // compile error: `message` is immutable
// Mutable requires explicit annotation
mut counter := 0
counter++
counter += 10
println(counter) // 11
// Function parameters are also immutable by default
greet('world')
}
fn greet(name string) {
// name = 'other' // compile error
println('Hello, ${name}')
}
fn increment(mut val int) {
val++ // allowed because parameter is mut
}
|
Strings
V strings are UTF-8, immutable by default, and have no null terminator (though they carry a .str pointer for C interop).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
fn main() {
s := 'Hello, V!'
println(s.len) // 9
println(s[0]) // 72 (byte value)
println(s[0..5]) // Hello (slicing)
println(s.to_upper()) // HELLO, V!
println(s.contains('V')) // true
println(s.replace('Hello', 'Goodbye')) // Goodbye, V!
// String interpolation
name := 'engineer'
version := 4
msg := 'Welcome, ${name}. V version ${version} is running.'
println(msg)
// Multiline strings
sql_query := "
SELECT *
FROM users
WHERE active = true
"
// Raw strings (no escape processing)
raw := r'C:\Users\alice\documents'
println(raw) // C:\Users\alice\documents
// String builder for performance
mut sb := strings.new_builder(64)
sb.write_string('foo')
sb.write_string('bar')
result := sb.str()
println(result) // foobar
}
|
Arrays and Maps
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
fn main() {
// Array literals
nums := [1, 2, 3, 4, 5]
println(nums[0]) // 1
println(nums.len) // 5
println(nums.last()) // 5
// Mutable array
mut items := ['apple', 'banana']
items << 'cherry' // append
items << ['date', 'elderberry'] // append slice
println(items)
// Array operations
doubled := nums.map(it * 2) // [2, 4, 6, 8, 10]
evens := nums.filter(it % 2 == 0) // [2, 4]
total := nums.reduce(fn(acc int, x int) int { return acc + x }, 0)
// 2D arrays
matrix := [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
println(matrix[1][2]) // 6
// Fixed-size arrays
fixed := [5]int{} // [0, 0, 0, 0, 0]
fixed2 := [3]string{'a', 'b', 'c'}
// Maps
mut scores := map[string]int{}
scores['alice'] = 95
scores['bob'] = 87
scores['carol'] = 91
// Map literal
config := {
'host': 'localhost'
'port': '5432'
'db': 'myapp'
}
// Check if key exists
if val := scores['alice'] {
println('Alice scored ${val}')
}
// Iterate
for key, val in scores {
println('${key}: ${val}')
}
}
|
Structs
Structs are the primary way to define data in V. There are no classes — V uses structs with methods.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
struct Point {
x f64
y f64
}
struct Rectangle {
mut:
width f64
height f64
label string
}
// Methods on structs
fn (r Rectangle) area() f64 {
return r.width * r.height
}
fn (r Rectangle) perimeter() f64 {
return 2 * (r.width + r.height)
}
// Mutable receiver — modifies the struct
fn (mut r Rectangle) scale(factor f64) {
r.width *= factor
r.height *= factor
}
fn main() {
// Struct initialization
p := Point{x: 3.0, y: 4.0}
println('Point: (${p.x}, ${p.y})')
mut rect := Rectangle{
width: 10.0
height: 5.0
label: 'main rect'
}
println('Area: ${rect.area()}')
println('Perimeter: ${rect.perimeter()}')
rect.scale(2.0)
println('Scaled area: ${rect.area()}') // 200
// Struct update syntax (copy with modifications)
rect2 := Rectangle{
...rect
label: 'copy'
}
println('${rect2.label}: ${rect2.area()}')
}
|
Interfaces
V interfaces are implicit — a type implements an interface simply by having the required methods. No implements keyword.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
interface Shape {
area() f64
name() string
}
struct Circle {
radius f64
}
fn (c Circle) area() f64 {
return 3.14159 * c.radius * c.radius
}
fn (c Circle) name() string {
return 'circle'
}
struct Square {
side f64
}
fn (s Square) area() f64 {
return s.side * s.side
}
fn (s Square) name() string {
return 'square'
}
fn print_shape_info(s Shape) {
println('${s.name()}: area = ${s.area():.2f}')
}
fn main() {
shapes := [Shape(Circle{radius: 5.0}), Square{side: 4.0}]
for shape in shapes {
print_shape_info(shape)
}
}
|
Sum Types
Sum types (tagged unions) are one of V’s more powerful features. They allow a variable to hold one of several possible types, and pattern matching handles each case.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// A JSON-like value type
type JsonValue = bool | f64 | int | string | []JsonValue | map[string]JsonValue
// A result type for a network operation
type NetworkResult = string | NetworkError
struct NetworkError {
code int
message string
}
fn describe(val JsonValue) string {
return match val {
bool { 'boolean: ${val}' }
int { 'integer: ${val}' }
f64 { 'float: ${val}' }
string { 'string: "${val}"' }
[]JsonValue { 'array with ${val.len} elements' }
map[string]JsonValue { 'object with ${val.len} keys' }
}
}
fn main() {
values := [
JsonValue(42),
JsonValue('hello'),
JsonValue(true),
JsonValue(3.14),
]
for v in values {
println(describe(v))
}
}
|
Enums
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
enum Direction {
north
south
east
west
}
enum HttpStatus {
ok = 200
not_found = 404
internal_error = 500
}
fn describe_direction(d Direction) string {
return match d {
.north { 'heading north' }
.south { 'heading south' }
.east { 'heading east' }
.west { 'heading west' }
}
}
fn main() {
dir := Direction.north
println(describe_direction(dir))
status := HttpStatus.not_found
println('Status code: ${int(status)}') // 404
// Enums in conditions
if status == .ok {
println('success')
} else {
println('something went wrong')
}
}
|
Modules
V’s module system maps directly to directories. Each .v file in a directory belongs to the same module.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// File: mathutils/geometry.v
module mathutils
pub fn circle_area(radius f64) f64 {
return 3.14159265 * radius * radius
}
pub fn hypotenuse(a f64, b f64) f64 {
return f64_sqrt(a * a + b * b)
}
// Private — not exported
fn f64_sqrt(x f64) f64 {
return x // V has math.sqrt in stdlib
}
|
1
2
3
4
5
6
7
|
// File: main.v
import mathutils
fn main() {
area := mathutils.circle_area(5.0)
println('Area: ${area:.4f}')
}
|
Defer
defer works identically to Go — the deferred call executes when the enclosing function returns, regardless of how it returns.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import os
fn process_file(path string) ! {
f := os.open(path)!
defer { f.close() } // always runs on return
content := f.read_to_end()!
println('Read ${content.len} bytes')
// Even if we return early here, f.close() runs
if content.len == 0 {
return error('empty file')
}
// ... process content
}
fn acquire_resources() {
println('acquiring lock')
defer { println('releasing lock') }
println('doing work')
// 'releasing lock' prints after 'doing work'
}
|
Compile-Time Conditionals
V’s $if and $for allow compile-time branching based on OS, compiler flags, or type information.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
fn platform_info() string {
$if linux {
return 'Running on Linux'
} $else $if macos {
return 'Running on macOS'
} $else $if windows {
return 'Running on Windows'
} $else {
return 'Unknown platform'
}
}
fn debug_log(msg string) {
$if debug {
println('[DEBUG] ${msg}')
}
}
// Compile-time type reflection
fn type_name[T]() string {
$if T is string {
return 'string'
} $else $if T is int {
return 'int'
} $else {
return 'unknown'
}
}
fn main() {
println(platform_info())
debug_log('this only appears with -d debug')
println(type_name[string]()) // string
println(type_name[int]()) // int
}
|
3. Memory Management — The Autofree Engine
Memory management is where V makes its most distinctive and controversial claims. Understanding how it actually works — not how it was marketed — is essential for evaluating the language.
V’s Memory Management Modes
V supports four memory management strategies, selectable at compile time:
| Flag |
Strategy |
| (default) |
Autofree — compile-time ownership analysis |
-gc boehm |
Conservative Boehm GC |
-gc gc |
V’s own simple GC |
-gc none |
Fully manual — no automatic freeing |
Autofree: How It Actually Works
Autofree is not a garbage collector. It’s a compile-time analysis pass that inserts free() calls into the generated C code at the point where a variable goes out of scope. For simple, linear ownership patterns, it works well and produces code with no leaks and no GC pauses.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
fn read_and_process() {
// V's autofree tracks that 'data' is owned here
data := get_data() // heap allocation
result := process(data) // data passed, still owned here
println(result)
// autofree inserts free(data) here automatically
}
fn get_data() string {
return 'some large dataset...'
}
fn process(s string) string {
return s.to_upper()
}
|
The generated C code (simplified) looks like:
1
2
3
4
5
6
7
8
|
void read_and_process() {
string data = get_data();
string result = process(data);
println(result);
// V inserts these:
string_free(&result);
string_free(&data);
}
|
Where Autofree Falls Short
The honest assessment: autofree works reliably for straightforward ownership patterns but hits edge cases in more complex code. Circular data structures, closures that capture variables, and certain patterns with shared ownership require manual intervention or GC mode.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// This pattern works fine with autofree
fn simple_ownership() {
data := fetch_records()
for record in data {
handle(record)
}
// autofree cleans up 'data' here
}
// This pattern can be tricky — autofree may not handle
// all the lifetime relationships correctly in complex cases
fn complex_sharing(items []string) []string {
mut result := []string{}
for item in items {
if item.len > 3 {
result << item // result borrows from items?
}
}
return result
}
|
For production applications where autofree behaves unexpectedly, using -gc boehm is a practical fallback. Boehm GC is a mature conservative collector that handles all the edge cases at the cost of GC pauses.
Heap Allocation with &
By default, struct instances in V are stack-allocated. To allocate on the heap, use &:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
struct Node {
val int
mut:
next ?&Node // optional pointer to next node
}
fn build_list(values []int) ?&Node {
if values.len == 0 {
return none
}
mut head := &Node{val: values[0]}
mut current := head
for i := 1; i < values.len; i++ {
new_node := &Node{val: values[i]}
current.next = new_node
current = new_node
}
return head
}
fn print_list(node ?&Node) {
mut cur := node
for cur != none {
n := cur?
print('${n.val} -> ')
cur = n.next
}
println('nil')
}
fn main() {
list := build_list([1, 2, 3, 4, 5])
print_list(list) // 1 -> 2 -> 3 -> 4 -> 5 -> nil
}
|
Manual Memory Management with -gc none
For embedded targets or performance-critical code where you want zero overhead:
1
2
3
4
5
6
7
8
9
10
11
|
// With -gc none, you manage memory manually
// V still prevents double-free and use-after-free
// through compile-time checks where possible
fn manual_example() {
mut buf := unsafe { malloc(1024) }
defer { unsafe { free(buf) } }
// Use buf...
// free() called automatically via defer
}
|
V vs Rust’s Borrow Checker vs Zig’s Allocators
| Approach |
V Autofree |
Rust Borrow Checker |
Zig Explicit Allocators |
| Mental model |
Automatic |
Explicit ownership rules |
Explicit allocator passing |
| Compile-time guarantee |
Partial |
Complete |
Partial (allocator can fail) |
| Runtime overhead |
None |
None |
None |
| Learning curve |
Low |
High |
Medium |
| Edge case handling |
GC fallback |
Borrow checker enforces |
Developer responsibility |
| Maturity |
Beta |
Production |
Production |
V’s approach is the least intrusive but also the least reliable. Rust’s borrow checker gives you a mathematical guarantee that’s enforced at compile time, at the cost of significant learning investment. Zig’s approach requires you to pass allocators explicitly everywhere, which is verbose but deterministic. V’s autofree is a sweet spot for simple programs and a potential liability for complex ones.
4. Option and Result Types
V eliminates null pointer exceptions entirely. There is no null or nil (outside of unsafe pointer operations). Instead, V uses option types and result types.
Option Types
?T means “a value of type T, or nothing.”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
fn find_user(id int) ?string {
users := {
1: 'Alice'
2: 'Bob'
3: 'Carol'
}
return users[id] // returns ?string — some or none
}
fn main() {
// Method 1: or block
name := find_user(1) or { 'unknown' }
println(name) // Alice
not_found := find_user(99) or { 'unknown' }
println(not_found) // unknown
// Method 2: if let (unwrap in condition)
if user := find_user(2) {
println('Found: ${user}')
} else {
println('Not found')
}
// Method 3: ? propagation — propagates none upward
process_user(1) or { println('Error: ${err}') }
}
fn process_user(id int) ?string {
user := find_user(id)? // ? propagates none if find_user returns none
return user.to_upper()
}
|
Result Types
!T means “a value of type T, or an error.” This is V’s equivalent of Rust’s Result<T, E> or Go’s (T, error) pattern.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
import os
fn read_config(path string) !map[string]string {
content := os.read_file(path)! // ! propagates error upward
mut config := map[string]string{}
lines := content.split('\n')
for line in lines {
trimmed := line.trim_space()
if trimmed.len == 0 || trimmed.starts_with('#') {
continue
}
parts := trimmed.split('=')
if parts.len != 2 {
return error('invalid config line: "${trimmed}"')
}
config[parts[0].trim_space()] = parts[1].trim_space()
}
return config
}
fn main() {
config := read_config('app.conf') or {
eprintln('Failed to read config: ${err}')
return
}
host := config['host'] or { 'localhost' }
println('Connecting to ${host}')
}
|
Combining ? and ! with Propagation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
struct DatabaseConfig {
host string
port int
name string
}
fn parse_port(s string) !int {
port := s.int()
if port <= 0 || port > 65535 {
return error('invalid port: ${s}')
}
return port
}
fn load_db_config(path string) !DatabaseConfig {
config := read_config(path)! // propagates read error
host := config['host'] or { return error('missing host in config') }
port_str := config['port'] or { return error('missing port in config') }
db_name := config['database'] or { return error('missing database in config') }
port := parse_port(port_str)! // propagates parse error
return DatabaseConfig{
host: host
port: port
name: db_name
}
}
fn main() {
db_config := load_db_config('database.conf') or {
eprintln('Config error: ${err}')
exit(1)
}
println('Connecting to ${db_config.host}:${db_config.port}/${db_config.name}')
}
|
This pattern is clean and explicit. Unlike Go’s repetitive if err != nil blocks, V’s ! propagation reduces boilerplate while keeping error handling visible in function signatures.
5. Concurrency — Spawn, Channels, and Shared State
V’s concurrency model is closer to Go’s goroutines than to Rust’s async/await. The primitives are spawn (start a new OS thread), chan (typed channels), and shared/lock for shared mutable state.
Spawn
spawn creates a new OS thread (not a green thread — V doesn’t have a goroutine scheduler yet).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
import time
fn worker(id int, jobs chan int, results chan int) {
for {
job := <-jobs or { break } // channel closed — exit loop
println('Worker ${id} processing job ${job}')
time.sleep(10 * time.millisecond)
results <- job * job // send result
}
println('Worker ${id} done')
}
fn main() {
jobs := chan int{cap: 100}
results := chan int{cap: 100}
// Start 4 workers
for i in 1..5 {
spawn worker(i, jobs, results)
}
// Send 20 jobs
for i in 1..21 {
jobs <- i
}
jobs.close()
// Collect results
mut total := 0
for _ in 1..21 {
result := <-results
total += result
}
println('Sum of squares: ${total}')
}
|
Channels in Detail
V channels are typed and can be buffered or unbuffered.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
fn producer(ch chan string) {
messages := ['alpha', 'beta', 'gamma', 'delta']
for msg in messages {
ch <- msg
println('Sent: ${msg}')
}
ch.close()
}
fn consumer(ch chan string, done chan bool) {
for {
msg := <-ch or { break }
println('Received: ${msg}')
}
done <- true
}
fn main() {
ch := chan string{cap: 2} // buffered channel, capacity 2
done := chan bool{} // unbuffered
spawn producer(ch)
spawn consumer(ch, done)
// Wait for consumer to finish
<-done
println('All done')
}
|
Shared Variables and Locking
When multiple threads need to share mutable state, V uses shared types with explicit lock/rlock blocks.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
struct Counter {
mut:
value int
}
fn increment_many(shared counter Counter, n int, done chan bool) {
for _ in 0..n {
lock counter {
counter.value++
}
}
done <- true
}
fn main() {
shared counter := Counter{}
done := chan bool{}
num_goroutines := 10
increments_each := 1000
for _ in 0..num_goroutines {
spawn increment_many(shared counter, increments_each, done)
}
// Wait for all to complete
for _ in 0..num_goroutines {
<-done
}
rlock counter {
println('Final count: ${counter.value}')
// Should be 10000
}
}
|
Threading Model Limitations
V’s threading model has an important constraint: it uses OS threads, not lightweight goroutines. This means:
- Creating thousands of
spawn calls is expensive (each is a real thread)
- There’s no goroutine scheduler that multiplexes many logical tasks onto few OS threads
- Channel operations that block, block an OS thread
For most systems programming tasks — a CLI tool, a moderate-load web server, a data processing pipeline — this is fine. For highly concurrent workloads handling tens of thousands of simultaneous connections, Go’s goroutine model is more appropriate. This is an area where V explicitly lags behind Go.
6. Fast Compilation — How V Actually Achieves It
V’s compilation speed is genuine and is one of its strongest attributes. Understanding why requires understanding the compilation pipeline.
The Compilation Pipeline
V does not compile directly to native machine code. It compiles to C, then calls a C compiler (typically tcc for development or gcc/clang for release) to produce the final binary.
V source (.v files)
↓ V compiler (written in V)
C source (.c file)
↓ tcc / gcc / clang
Native binary
This is the same approach used by early versions of Cython, and it’s what gives V its compilation speed advantage: the V-to-C translation is fast, and TCC (Tiny C Compiler) is extremely fast at compiling C. TCC compiles at roughly 700MB/s of C source — much faster than gcc or clang.
Building a Project
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# Run directly (transpile + compile + run, no binary kept)
v run main.v
# Build a binary
v main.v
# or for a directory:
v .
# Build with optimizations (uses gcc/clang instead of tcc)
v -prod main.v
# Build with specific C compiler
v -cc clang main.v
v -cc gcc main.v
# Build for a specific target
v -os linux -arch amd64 main.v
v -os windows -arch amd64 main.v
v -os macos -arch arm64 main.v
# View generated C code
v -o output.c main.v
|
Measured Compilation Speed
A real 5000-line V project typically compiles in 0.3–0.8 seconds with TCC (development mode). With -prod (gcc optimizations), it takes longer — typically 2–5 seconds — but produces faster binaries.
For comparison:
- Go: 0.5–2s for similar-sized projects (fast, comparable)
- Rust: 10–60s for similar-sized projects (much slower)
- Zig: 1–5s (faster than Rust, comparable to Go)
V’s compile speed is not magic — it’s the result of a simple type system, no templates, and TCC. But the result is real and valuable for fast iteration.
Hot Code Reloading
V supports hot code reloading for development — modify a function, save the file, and the running program picks up the change without restart.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// Annotate functions with [live] to enable hot reloading
import time
[live]
fn print_message() {
// Change this string and save — the running program updates
println('Hello from a live-reloaded function!')
}
fn main() {
for {
print_message()
time.sleep(500 * time.millisecond)
}
}
|
1
2
|
# Run with live reloading enabled
v -live run main.v
|
Hot reloading only works for functions marked [live]. It’s most useful for UI development and rapid experimentation. It doesn’t support all types of changes (struct modifications, new imports, etc.) and will fall back to a full restart for those cases.
7. The Standard Library
V ships with a standard library that covers the essentials. It’s not as comprehensive as Go’s or Python’s, but the important building blocks are there.
HTTP Client and Server with vweb
1
2
3
4
5
6
7
8
9
10
11
12
|
// Simple HTTP GET
import net.http
fn main() {
resp := http.get('https://api.github.com/users/vlang') or {
eprintln('Request failed: ${err}')
return
}
println('Status: ${resp.status_code}')
println('Body length: ${resp.body.len}')
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// vweb server
import vweb
struct App {
vweb.Context
}
fn main() {
vweb.run(&App{}, 8080)
}
['/']
fn (mut app App) index() vweb.Result {
return app.text('Hello from vweb!')
}
['/users/:id']
fn (mut app App) get_user(id string) vweb.Result {
// In a real app, fetch from database
return app.json('{"id": "${id}", "name": "Alice"}')
}
['/health']
fn (mut app App) health() vweb.Result {
return app.json('{"status": "ok"}')
}
|
1
2
|
v run server.v
# Listening on http://localhost:8080
|
JSON Parsing — Compile-Time Generated
V’s JSON serialization/deserialization is generated at compile time from struct definitions. There’s no runtime reflection.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import json
struct User {
id int [json: 'user_id']
name string
email string
active bool [json: 'is_active']
tags []string
}
struct ApiResponse {
data []User
total int
page int
per_page int [json: 'per_page']
}
fn main() {
json_str := '{"user_id": 1, "name": "Alice", "email": "alice@example.com", "is_active": true, "tags": ["admin", "user"]}'
user := json.decode(User, json_str) or {
eprintln('JSON decode error: ${err}')
return
}
println('User: ${user.name} (${user.email})')
println('Active: ${user.active}')
println('Tags: ${user.tags}')
// Encode back to JSON
encoded := json.encode(user)
println(encoded)
}
|
OS and File System
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
import os
fn main() {
// File operations
os.write_file('hello.txt', 'Hello, V!') or { panic(err) }
content := os.read_file('hello.txt') or { panic(err) }
println(content)
// Directory operations
files := os.ls('.') or { [] }
for f in files {
info := os.stat(f) or { continue }
println('${f}: ${info.size} bytes')
}
// Environment
home := os.getenv('HOME')
println('Home: ${home}')
os.setenv('MY_VAR', 'my_value', true)
// Process execution
result := os.execute('ls -la')
if result.exit_code == 0 {
println(result.output)
}
// Path operations
abs := os.abs_path('relative/path')
dir := os.dir('/some/path/file.txt') // /some/path
base := os.base('/some/path/file.txt') // file.txt
ext := os.file_ext('image.png') // .png
println('${abs}, ${dir}, ${base}, ${ext}')
}
|
Crypto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import crypto.sha256
import crypto.md5
import crypto.hmac
import encoding.hex
fn main() {
data := 'Hello, V!'
// SHA-256
hash := sha256.sum(data.bytes())
println('SHA-256: ${hex.encode(hash)}')
// MD5 (for legacy compatibility — don't use for security)
md5_hash := md5.sum(data.bytes())
println('MD5: ${hex.encode(md5_hash)}')
// HMAC-SHA256
key := 'secret-key'
mac := hmac.new(key.bytes(), data.bytes(), sha256.sum, sha256.block_size)
println('HMAC: ${hex.encode(mac)}')
}
|
Database: SQLite, PostgreSQL, MySQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
import db.sqlite
struct Person {
id int [primary; sql: serial]
name string [nonull]
age int
}
fn main() {
db := sqlite.connect('people.db') or { panic(err) }
defer { db.close() }
// Create table from struct definition
sql db {
create table Person
}
// Insert
sql db {
insert Person{name: 'Alice', age: 30}
insert Person{name: 'Bob', age: 25}
}
// Select
people := sql db {
select from Person where age > 20 order by name
}
for p in people {
println('${p.name}: ${p.age}')
}
// Select with limit
first_two := sql db {
select from Person limit 2
}
println('First 2: ${first_two.len} records')
}
|
For PostgreSQL, the pattern is similar but uses db.pg:
1
2
3
4
5
6
7
8
9
10
11
|
import db.pg
fn connect_pg() !pg.DB {
return pg.connect(pg.Config{
host: 'localhost'
port: 5432
user: 'myuser'
password: 'mypassword'
dbname: 'mydb'
})!
}
|
Graphics with gg
V includes gg (a thin wrapper over sokol) for simple 2D graphics:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
import gg
import gx
struct App {
mut:
ctx &gg.Context = unsafe { nil }
angle f32
}
fn (mut app App) frame() {
app.ctx.begin()
// Draw a rotating rectangle
x := 400 + int(100 * f32(math.cos(app.angle)))
y := 300 + int(100 * f32(math.sin(app.angle)))
app.ctx.draw_rect_filled(x - 25, y - 25, 50, 50, gx.blue)
app.angle += 0.02
app.ctx.end()
}
fn main() {
mut app := App{}
app.ctx = gg.new_context(gg.Config{
width: 800
height: 600
window_title: 'V Graphics Demo'
frame_fn: app.frame
user_data: &app
})
app.ctx.run()
}
|
8. C Interoperability
V’s C interoperability is first-class. Since V compiles through C, calling C functions and libraries is straightforward.
Calling C Functions from V
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Include C headers
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Declare the C functions you want to use
fn C.printf(format &u8, ...) int
fn C.strlen(s &u8) int
fn C.malloc(size usize) voidptr
fn C.free(ptr voidptr)
fn C.memcpy(dst voidptr, src voidptr, n usize) voidptr
fn main() {
// Call C printf directly
msg := c'Hello from C printf!\n'
C.printf(msg)
// Use C strlen
s := c'hello world'
length := C.strlen(s)
println('Length: ${length}')
// Manual malloc/free
buf := C.malloc(256)
defer { C.free(buf) }
// ... use buf ...
}
|
Using External C Libraries
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// Use #flag to pass compiler/linker flags
#flag -lm // link math library
#flag -lssl -lcrypto // link OpenSSL
#include <math.h>
#include <openssl/sha.h>
fn C.sqrt(x f64) f64
fn C.pow(base f64, exp f64) f64
fn C.SHA256(data &u8, length usize, digest &u8) &u8
fn main() {
// Use C math functions
result := C.sqrt(144.0)
println('sqrt(144) = ${result}') // 12.0
cube := C.pow(3.0, 3.0)
println('3^3 = ${cube}') // 27.0
}
|
Wrapping a C Library
A common pattern: wrap a C library in a V module.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
// File: redis/redis.v
module redis
#flag -lhiredis
#include <hiredis/hiredis.h>
// C struct mappings
struct C.redisContext {
err int
errstr [128]u8
}
struct C.redisReply {
@type int
integer i64
len int
str &u8
elements usize
element &&C.redisReply
}
fn C.redisConnect(ip &u8, port int) &C.redisContext
fn C.redisFree(c &C.redisContext)
fn C.redisCommand(c &C.redisContext, format &u8, ...) &C.redisReply
fn C.freeReplyObject(reply voidptr)
pub struct Client {
mut:
ctx &C.redisContext
}
pub fn connect(host string, port int) !Client {
ctx := C.redisConnect(host.str, port)
if ctx == unsafe { nil } {
return error('failed to allocate redis context')
}
if ctx.err != 0 {
return error('redis connection error: ${unsafe { cstring_to_vstring(&ctx.errstr[0]) }}')
}
return Client{ctx: ctx}
}
pub fn (mut c Client) close() {
C.redisFree(c.ctx)
}
pub fn (mut c Client) set(key string, value string) !bool {
reply := C.redisCommand(c.ctx, c'SET %s %s', key.str, value.str)
defer { C.freeReplyObject(reply) }
if reply == unsafe { nil } {
return error('SET command failed')
}
return true
}
pub fn (mut c Client) get(key string) !string {
reply := C.redisCommand(c.ctx, c'GET %s', key.str)
defer { C.freeReplyObject(reply) }
if reply == unsafe { nil } {
return error('GET command failed')
}
if reply.str == unsafe { nil } {
return error('key not found')
}
return unsafe { cstring_to_vstring(reply.str) }
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// main.v
import redis
fn main() {
mut client := redis.connect('localhost', 6379) or {
eprintln('Connection failed: ${err}')
exit(1)
}
defer { client.close() }
client.set('greeting', 'Hello from V!') or { panic(err) }
val := client.get('greeting') or { panic(err) }
println(val) // Hello from V!
}
|
Calling V from C
V can also expose functions to be called from C:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// mylib.v
module main
[export: 'add_numbers']
pub fn add_numbers(a int, b int) int {
return a + b
}
[export: 'greet']
pub fn greet(name &u8) &u8 {
s := unsafe { cstring_to_vstring(name) }
result := 'Hello, ${s}!'
return result.str
}
|
1
2
|
# Compile as a shared library
v -shared mylib.v -o libmylib.so
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// caller.c
#include <stdio.h>
extern int add_numbers(int a, int b);
extern const char* greet(const char* name);
int main() {
int sum = add_numbers(3, 4);
printf("Sum: %d\n", sum); // 7
const char* msg = greet("World");
printf("%s\n", msg); // Hello, World!
return 0;
}
|
9. V’s Built-In ORM
V includes an ORM that’s notable for a systems language. Most systems languages leave database access to third-party libraries; V bakes it in. The ORM uses struct annotations to define the schema and a special sql block syntax that feels like embedded SQL but is actually compiled and type-checked at compile time.
Defining Models
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import db.sqlite
// Struct annotations define the table schema
struct Author {
id int [primary; sql: serial]
name string [nonull; sql_type: 'VARCHAR(100)']
email string [unique; nonull]
joined_at string [sql_type: 'DATETIME']
}
struct Post {
id int [primary; sql: serial]
author_id int [nonull]
title string [nonull; sql_type: 'VARCHAR(255)']
body string [sql_type: 'TEXT']
published bool
views int
}
|
CRUD Operations
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
fn main() {
db := sqlite.connect(':memory:') or { panic(err) }
defer { db.close() }
// Create tables
sql db {
create table Author
create table Post
}
// INSERT
sql db {
insert Author{
name: 'Alice Chen'
email: 'alice@example.com'
joined_at: '2024-01-15'
}
insert Author{
name: 'Bob Martinez'
email: 'bob@example.com'
joined_at: '2024-03-20'
}
}
sql db {
insert Post{
author_id: 1
title: 'Getting Started with V'
body: 'V is a simple, fast, and safe language...'
published: true
views: 1250
}
insert Post{
author_id: 1
title: 'Memory Management in V'
body: 'V uses autofree for automatic memory management...'
published: true
views: 890
}
insert Post{
author_id: 2
title: 'V vs Go: A Comparison'
body: 'Both languages prioritize simplicity...'
published: false
views: 0
}
}
// SELECT — basic
all_authors := sql db {
select from Author
}
println('Authors: ${all_authors.len}')
// SELECT — with WHERE
published_posts := sql db {
select from Post where published == true
}
println('Published posts: ${published_posts.len}')
// SELECT — with WHERE and ORDER BY
popular_posts := sql db {
select from Post where views > 500 order by views
}
for post in popular_posts {
println('${post.title}: ${post.views} views')
}
// SELECT — with LIMIT
top_post := sql db {
select from Post order by views limit 1
}
if top_post.len > 0 {
println('Most viewed: ${top_post[0].title}')
}
// UPDATE
sql db {
update Post set views = views + 1 where id == 1
}
// UPDATE — multiple fields
sql db {
update Post set published = true, views = 100 where author_id == 2
}
// DELETE
sql db {
delete from Post where published == false
}
// Count remaining
remaining := sql db {
select from Post
}
println('Remaining posts: ${remaining.len}')
}
|
ORM with PostgreSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
import db.pg
struct Product {
id int [primary; sql: serial]
sku string [unique; nonull; sql_type: 'VARCHAR(50)']
name string [nonull; sql_type: 'VARCHAR(200)']
price_cents int [nonull]
stock int
active bool
}
fn main() {
db := pg.connect(pg.Config{
host: 'localhost'
port: 5432
user: 'appuser'
password: 'secret'
dbname: 'store'
}) or {
eprintln('DB connection failed: ${err}')
exit(1)
}
defer { db.close() }
sql db {
create table Product
}
sql db {
insert Product{
sku: 'WIDGET-001'
name: 'Standard Widget'
price_cents: 999
stock: 100
active: true
}
}
// Find active products under $20
affordable := sql db {
select from Product where active == true && price_cents < 2000
}
for p in affordable {
price := f64(p.price_cents) / 100.0
println('${p.sku}: ${p.name} - \$${price:.2f} (${p.stock} in stock)')
}
}
|
ORM Limitations
V’s ORM is functional but has limitations compared to mature ORMs like SQLAlchemy, ActiveRecord, or GORM:
- No join support in the query syntax (you must write raw SQL for joins)
- No migration tooling (you get
create table, not alter table)
- Limited aggregate functions
- No relationship/association handling
For simple CRUD operations on a single table, it’s convenient. For complex relational queries, drop down to raw SQL:
1
2
3
4
5
6
7
8
|
// Raw SQL fallback when ORM isn't enough
result := db.exec('SELECT a.name, COUNT(p.id) as post_count FROM authors a LEFT JOIN posts p ON p.author_id = a.id GROUP BY a.id ORDER BY post_count DESC') or {
panic(err)
}
for row in result {
println('${row.vals[0]}: ${row.vals[1]} posts')
}
|
The V Command Line
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
# Run a V file
v run hello.v
# Run the current directory (looks for main.v or the module)
v run .
# Build a binary
v hello.v # produces ./hello
v -o myapp . # build directory, custom output name
# Build with optimizations
v -prod . # enables optimizations, strips debug info, uses gcc/clang
# Build with debug symbols
v -g . # include debug info for gdb/lldb
# Build and strip
v -prod -strip .
# Cross-compile
v -os linux -arch amd64 .
v -os windows -arch amd64 .
v -os macos -arch arm64 .
# Produce C source instead of binary (useful for inspection)
v -o output.c .
|
Testing
V has a built-in test framework. Test files end in _test.v.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// File: math_test.v
module math
fn test_add() {
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
}
fn test_multiply() {
assert multiply(3, 4) == 12
assert multiply(-2, 5) == -10
assert multiply(0, 99) == 0
}
fn test_divide() {
result := divide(10, 2) or { panic('unexpected error') }
assert result == 5.0
// Test error case
err_result := divide(10, 0)
assert err_result == none
}
|
1
2
3
4
5
6
7
8
9
10
11
|
# Run tests in current directory
v test .
# Run tests in a specific file
v test math_test.v
# Run tests with verbose output
v -stats test .
# Run a specific test function
v test . -run test_add
|
1
2
3
4
5
6
7
8
|
# Format a file in place
v fmt -w hello.v
# Format all V files in directory
v fmt -w .
# Check formatting without modifying (for CI)
v fmt -diff .
|
Documentation
V generates documentation from doccomments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// User represents a registered user in the system.
// Fields are immutable by default — use `mut` to allow modification.
pub struct User {
pub:
id int
name string
email string
pub mut:
active bool
}
// new_user creates a new User with the given name and email.
// Returns an error if name or email is empty.
pub fn new_user(name string, email string) !User {
if name.len == 0 {
return error('name cannot be empty')
}
if email.len == 0 || !email.contains('@') {
return error('invalid email address')
}
return User{
name: name
email: email
active: true
}
}
|
1
2
3
4
5
|
# Generate HTML documentation
v doc .
# View docs in browser
v doc -open .
|
Package Manager — vpm
V has its own package manager called vpm.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Install a package
v install username.packagename
# Install from GitHub
v install https://github.com/user/vpackage
# List installed packages
v list
# Update all packages
v update
# Remove a package
v remove username.packagename
|
Packages are installed to ~/.vmodules by default and imported by module name. The ecosystem is small compared to npm, pip, or crates.io, but it’s growing. Core infrastructure packages (HTTP clients, database drivers, serialization) exist. Niche domain packages are often missing, requiring C interop or rolling your own.
Here’s a non-trivial example that ties together several V features — concurrency, error handling, CLI argument parsing, and network I/O:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
|
import net
import os
import time
struct ScanResult {
port int
open bool
banner string
}
fn scan_port(host string, port int, timeout_ms int) ScanResult {
addr := '${host}:${port}'
conn := net.dial_tcp(addr) or {
return ScanResult{port: port, open: false}
}
defer { conn.close() }
// Try to read a banner (1 second timeout)
conn.set_read_timeout(time.millisecond * timeout_ms)
mut banner_bytes := []u8{len: 256}
bytes_read := conn.read(mut banner_bytes) or { 0 }
banner := if bytes_read > 0 {
banner_bytes[..bytes_read].bytestr().trim_space()
} else {
''
}
return ScanResult{port: port, open: true, banner: banner}
}
fn scan_range(host string, start int, end int, concurrency int) []ScanResult {
jobs := chan int{cap: end - start + 1}
results := chan ScanResult{cap: end - start + 1}
// Start workers
for _ in 0..concurrency {
spawn fn (host string, jobs chan int, results chan ScanResult) {
for {
port := <-jobs or { break }
results <- scan_port(host, port, 500)
}
}(host, jobs, results)
}
// Enqueue jobs
for port in start..end + 1 {
jobs <- port
}
jobs.close()
// Collect results
mut all_results := []ScanResult{}
for _ in start..end + 1 {
all_results << <-results
}
return all_results.filter(it.open)
}
fn main() {
args := os.args[1..]
if args.len < 2 {
eprintln('Usage: portscan <host> <start-port> [end-port] [concurrency]')
eprintln('Example: portscan localhost 1 1024 100')
exit(1)
}
host := args[0]
start_port := args[1].int()
end_port := if args.len >= 3 { args[2].int() } else { start_port }
concurrency := if args.len >= 4 { args[3].int() } else { 50 }
if start_port < 1 || end_port > 65535 || start_port > end_port {
eprintln('Invalid port range')
exit(1)
}
println('Scanning ${host} ports ${start_port}-${end_port} (concurrency: ${concurrency})')
start_time := time.now()
open_ports := scan_range(host, start_port, end_port, concurrency)
elapsed := time.since(start_time)
println('\nOpen ports (${elapsed.milliseconds()}ms):')
if open_ports.len == 0 {
println(' None found')
} else {
for r in open_ports {
if r.banner.len > 0 {
println(' ${r.port}/tcp OPEN "${r.banner}"')
} else {
println(' ${r.port}/tcp OPEN')
}
}
}
}
|
1
|
v run portscan.v localhost 1 1024 100
|
11. Honest Assessment — Where V Delivers and Where It Doesn’t
What V Actually Delivers Today
Compilation speed — genuine and significant. This is V’s most authentic selling point. Sub-second development builds are real. For a language that emphasizes fast iteration, this matters.
Simple, readable syntax. V code reads clearly. The Go-style syntax with mut explicitness and option/result types makes programs predictable. A new contributor to a V codebase can be productive within a day.
Option and result types — well-implemented. The ?T/!T/or {}/? propagation pattern is clean and genuinely eliminates a class of bugs. This is a real improvement over C and a usable alternative to Go’s (T, error) verbosity.
Built-in ORM — functional for simple use cases. Uncommon for a systems language and genuinely convenient for database-backed CLI tools and small web services.
C interoperability — excellent. V’s C interop is straightforward and allows you to leverage the entire C ecosystem without FFI ceremony. For a young language, this dramatically expands practical usability.
vweb — works for simple services. Not battle-tested at scale, but functional for internal tools and small APIs.
Where V Falls Short
Autofree maturity. The compile-time autofree engine works for common patterns but has edge cases in complex programs. Production applications often need to fall back to -gc boehm or careful manual management. Autofree is not equivalent to Rust’s borrow checker — it provides weaker guarantees and has known gaps. The V team continues to improve it, but it’s not yet a complete solution.
Ecosystem size. The package ecosystem is small. If you need a third-party library for a non-trivial task — OAuth2 client, YAML parser, Kafka producer, gRPC — you’ll either write it yourself, use C interop, or discover that a V package exists but is poorly maintained. This is the biggest practical barrier to adoption.
Threading model limitations. OS threads, not goroutines. For high-concurrency workloads, Go is a better choice. V’s threading story is serviceable for most tasks but not ideal for thousands of simultaneous connections.
Windows support historically weaker. V works on Windows, but the development experience is historically smoother on Linux and macOS. Windows users should expect occasional rough edges.
0.x versioning = breaking changes. V has not shipped 1.0. The language spec has evolved, and code written for 0.3.x doesn’t always compile on 0.4.x. This is a real risk for long-lived projects.
Error messages. V’s compiler error messages are improving but still lag behind Rust’s (which are extraordinary) and even Go’s. Debugging type errors can be frustrating.
Documentation. The official documentation is incomplete in places, and third-party documentation is sparse compared to Go or Rust. Learning V requires reading source code and examples more than you’d like.
Where V Makes Sense
V is a good choice when:
- You want fast compilation and simple syntax for CLI tools. A V CLI tool compiles faster than Go, reads clearly, and ships as a single static binary.
- You’re building small to medium web services where the built-in ORM and vweb reduce boilerplate.
- You’re doing embedded or low-level work and want C-level control with better ergonomics.
-gc none mode gives you deterministic memory management without writing raw C.
- You’re evaluating modern systems languages and want something with a gentler learning curve than Rust.
- You’re building a tool with heavy C library dependencies — the C interop story is genuinely easy.
- Fast iteration matters — the compile-reload loop in V is fast enough to feel interactive.
Where to Choose Something Else
Choose Go when:
- You need a mature ecosystem with excellent third-party library support
- You’re building high-concurrency services (goroutine scheduler > OS threads)
- You want a language that’s been at 1.0+ for 15 years
- You need broad hiring pool
Choose Rust when:
- You need mathematical guarantees about memory safety (not “probably safe”)
- You’re writing security-critical code (kernel drivers, cryptographic implementations)
- You need the maximum performance from your hardware
- You can accept the learning curve
Choose Zig when:
- You want C-level control with modern ergonomics and no GC at all
- You’re targeting embedded systems or writing C replacements
- You want explicit, deterministic control over every allocation
- You’re comfortable with a pre-1.0 language with higher raw capability than V
Choose C/C++ when:
- You’re working in a codebase that’s already C/C++
- You need every optimization flag and platform-specific feature
- You need maximum ecosystem compatibility
The Controversy — A Fair Retrospective
The V controversy of 2019–2021 was real and the criticisms were largely valid: features were claimed as working that weren’t, the documentation overstated the language’s capabilities, and early fund-raising was tied to features that took years to arrive (some still incomplete).
What’s also true: the language has shipped. The repository is public, the compiler works, the code is readable, and the language has been converging on its promises. Medvednikov and the V community have continued development through significant criticism.
Whether the original marketing was enthusiastic optimism or deliberate deception is a judgment call. What matters for engineers evaluating V in 2026 is simpler: look at what the language actually does today, run the code, check the compiler output, and measure whether it solves your problem. The code doesn’t lie.
V is not the Rust-beater it was once implied to be. It’s a genuinely interesting language with a distinctive niche: simple syntax, fast compilation, good C interop, and a batteries-included standard library — at the cost of a small ecosystem, an immature autofree system, and pre-1.0 instability. For the right use cases, it’s worth your time. Go in with accurate expectations and you won’t be disappointed.
Getting Started Checklist
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
# Install
git clone https://github.com/vlang/v && cd v && make && sudo ./v symlink
# Verify
v version
# Create a project
mkdir myproject && cd myproject
cat > main.v << 'EOF'
fn main() {
println('Hello from V!')
}
EOF
# Run
v run main.v
# Build optimized binary
v -prod main.v && ./main
# Format
v fmt -w main.v
# Test (create a test file first)
v test .
# Explore stdlib
v doc builtin
v doc os
v doc net.http
v doc json
|
The language documentation lives at https://docs.vlang.io and the source code for the standard library at ~/.vmodules/v/ after installation is highly readable. Reading stdlib source is the fastest way to learn idiomatic V.
V occupies an interesting position in the systems language landscape: more pragmatic than Rust, faster-compiling than Go (in development mode), simpler than Zig, and with a batteries-included standard library that belies its size. If fast iteration, readable code, and C interoperability are your priorities — and you can live with a small ecosystem and pre-1.0 stability — V is worth a serious evaluation.
Comments