LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

V: Fast Compilation and Simple Systems Programming

vvlangsystems-programmingcompiledmemory-safetyfast-compilation
Contents

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')
}

10. Tooling

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

Formatting

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.

A Real CLI Tool: Port Scanner

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