Intermediate

I/O Operations in Odin

Learn console output, reading from stdin, and file reading and writing in Odin with Docker-ready examples and explicit error handling

Input and output are where a program meets the outside world – the terminal, the filesystem, and the user. Odin approaches I/O the way it approaches everything else: explicitly. There are no exceptions to catch and no hidden magic. Instead, procedures that can fail return an extra value you are expected to check, which fits Odin’s imperative, data-oriented style.

Most console output lives in the core:fmt package (which you already met in Hello World), while file and stream operations live in core:os. In this tutorial you will move beyond a single println and learn formatted output verbs, how to read a line from standard input, and how to write and read files with proper error handling.

Because Odin has no exceptions, error handling relies on multiple return values. A procedure like os.read_entire_file hands back both the data and a success boolean. Checking that boolean is not optional politeness – it is how you write correct Odin. We will use this pattern throughout.

Formatted Console Output

The core:fmt package offers several output procedures. println prints its arguments and adds a newline; printf uses C-style format verbs and requires an explicit \n; and printfln is printf with an automatic newline.

Create a file named console_output.odin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "core:fmt"

main :: proc() {
    // println adds a trailing newline automatically
    fmt.println("Line one")
    fmt.println("Line two")

    // printf uses C-style verbs and needs an explicit \n
    fmt.printf("Decimal: %d\n", 42)
    fmt.printf("Float (3 dp): %.3f\n", 2.71828)
    fmt.printf("String: %s\n", "Odin")
    fmt.printf("Boolean: %t\n", true)

    // printfln is printf plus an automatic newline
    fmt.printfln("Hex: %x, Octal: %o, Binary: %b", 255, 8, 5)

    // %v prints any value using its default format
    point := [2]int{3, 7}
    fmt.printfln("Vector: %v", point)
}

The format verbs mirror C’s printf family: %d for integers, %s for strings, %t for booleans, and %.3f to fix a float to three decimal places. The %x, %o, and %b verbs render integers in hexadecimal, octal, and binary. The universal %v verb prints any value using its default representation – handy for arrays, structs, and enums where you do not want to spell out each field. Running this program prints:

Line one
Line two
Decimal: 42
Float (3 dp): 2.718
String: Odin
Boolean: true
Hex: ff, Octal: 10, Binary: 101
Vector: [3, 7]

Reading From Standard Input

To read what a user types, use os.read with os.stdin and a byte buffer. In recent Odin, os.read returns the number of bytes read plus an os.Error, which is a union that compares equal to nil on success.

Create a file named read_input.odin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "core:fmt"
import "core:os"
import "core:strings"

main :: proc() {
    fmt.print("Enter your name: ")

    buf: [256]byte
    n, err := os.read(os.stdin, buf[:])
    if err != nil {
        fmt.eprintln("Error: could not read input")
        os.exit(1)
    }

    // buf[:n] is the slice of bytes actually read;
    // trim_space removes the trailing newline
    input := strings.trim_space(string(buf[:n]))
    fmt.printfln("Hello, %s! Your name has %d characters.", input, len(input))
}

A few things worth noting: buf[:] turns the fixed [256]byte array into a slice that os.read can fill, and buf[:n] slices out only the bytes that were actually read. Converting a byte slice to a string with string(...) is a cheap view (no copy), and strings.trim_space strips the trailing newline the terminal appends. Because this program waits on input, pipe a value in when running non-interactively:

1
2
echo "Ada" | docker run --rm -i -v $(pwd):/app -w /app primeimages/odin:latest \
  sh -c "cp read_input.odin /tmp/read_input.odin && cd /tmp && odin run ."

With Ada as input, it prints:

Enter your name: Hello, Ada! Your name has 3 characters.

Writing and Reading Files

For whole-file operations, core:os provides two convenient procedures. os.write_entire_file takes a filename and a []byte and returns a success boolean. os.read_entire_file returns the file’s contents as a []byte alongside its own success boolean. This next example writes several lines to a file, then reads them back – checking both operations for failure.

Create a file named io_operations.odin:

 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
package main

import "core:fmt"
import "core:os"
import "core:strings"

main :: proc() {
    // --- Formatted console output ---
    name := "Odin"
    year := 2016
    pi := 3.14159

    fmt.println("=== I/O Operations in Odin ===")
    fmt.printfln("Language: %s (since %d)", name, year)
    fmt.printfln("Pi to 2 decimals: %.2f", pi)

    // --- Writing to a file ---
    lines := []string{
        "Odin was created in 2016.",
        "It is a data-oriented systems language.",
        "Error handling uses multiple return values.",
    }
    content := strings.join(lines, "\n")
    defer delete(content)

    if !os.write_entire_file("notes.txt", transmute([]byte)content) {
        fmt.eprintln("Error: failed to write notes.txt")
        os.exit(1)
    }
    fmt.println("Wrote notes.txt successfully")

    // --- Reading the file back with error handling ---
    data, ok := os.read_entire_file("notes.txt")
    if !ok {
        fmt.eprintln("Error: failed to read notes.txt")
        os.exit(1)
    }
    defer delete(data)

    fmt.println("--- notes.txt contents ---")
    fmt.print(string(data))
    fmt.println()
}

Several Odin idioms appear here. strings.join allocates a new string, so we pair it with defer delete(content) to free that memory when the scope exits – Odin has no garbage collector, and defer is the idiomatic cleanup tool. transmute([]byte)content reinterprets the string as a []byte without copying, since both are {pointer, length} under the hood. The if !os.write_entire_file(...) and if !ok checks are the heart of Odin error handling: no exception unwinds the stack for you, so you branch on the returned boolean and call fmt.eprintln (which writes to standard error) plus os.exit(1) when something goes wrong.

Running with Docker

The comprehensive example writes and reads a file, so it produces the same output every time. Run it with the official Odin image:

1
2
3
4
5
6
# Pull the official image
docker pull primeimages/odin:latest

# Run the I/O operations example
docker run --rm -v $(pwd):/app -w /app primeimages/odin:latest \
  sh -c "cp io_operations.odin /tmp/io_operations.odin && cd /tmp && odin run ."

As with Hello World, the source is copied into /tmp because odin run . compiles every .odin file in the working directory and needs a writable location for the output binary and the notes.txt it creates.

Expected Output

=== I/O Operations in Odin ===
Language: Odin (since 2016)
Pi to 2 decimals: 3.14
Wrote notes.txt successfully
--- notes.txt contents ---
Odin was created in 2016.
It is a data-oriented systems language.
Error handling uses multiple return values.

Key Concepts

  • core:fmt for output, core:os for files – Console formatting lives in fmt (println, printf, printfln), while file and stream I/O lives in os.
  • Format verbs are C-style%d, %s, %t, %.2f, %x, and the universal %v control how values are rendered; printf needs an explicit \n but printfln does not.
  • Errors are return values, not exceptions – Procedures like os.write_entire_file and os.read_entire_file hand back a success boolean (or an os.Error) that you must check explicitly.
  • os.read fills a byte buffer – Read from os.stdin into a []byte, then slice buf[:n] to get exactly the bytes read and convert to a string.
  • transmute reinterprets without copyingtransmute([]byte)someString converts between a string and a []byte cheaply because they share the same memory layout.
  • defer delete(...) cleans up allocations – With no garbage collector, pair heap-allocating calls like strings.join and os.read_entire_file with defer delete(...) to free memory when the scope ends.
  • fmt.eprintln writes to stderr – Send diagnostics and error messages to standard error, keeping them separate from normal program output on stdout.

Running Today

All examples can be run using Docker:

docker pull primeimages/odin:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining