Str and StrView in practice

2026-11-07muleGuide

Str owns its bytes and can grow. StrView borrows bytes that something else owns and never allocates. The working pattern is to put text into a Str once, then pass StrViews around to read it, search it and cut it. Copy a view into a new Str only when the bytes must outlive the owner. Every example below is a file in notes/str-examples/. Each output block is the real output of running that file.

Running the examples

Run these from the repository root. The examples read data/pg2554.txt, which is the Project Gutenberg text of Crime and Punishment. Each file includes src/mem/mem.c and src/str/str.c directly, the same way main.c does, so a single compiler call builds it.

cc -std=c11 -Isrc -fsanitize=address,undefined \
   notes/str-examples/01_build.c -o build/ex && ./build/ex

Keep the sanitizer flag on while you learn. It turns most memory mistakes into a report that names the line. The mistakes section shows two of those reports.

The two types

Both types hold a pointer and a length. The difference is ownership. A Str is responsible for its heap buffer, so it must be freed. A StrView points at bytes that belong to someone else, so it is never freed. It is only valid while the owner is alive and has not moved its buffer.

Str text     data ──► C r i m e   a n d   P u n i s h m e n t \0 · · · · · · · · · ·
             len 20, capacity 30                                   spare capacity ─┘
                      ▲                   ▲
StrView word     data ┘ len 5             │   word = "Crime"
StrView tail                         data ┘ len 10   tail = "Punishment"

The views above cost nothing to make. They are two machine words each. Slicing text a thousand times allocates nothing.

StrStrView
Fieldsdata, len, capacity, allocdata, len
Owns memoryYes. Call str_free when done. A Str in an arena is also freed by arena_free.No. Never free it.
Can changeYes, through str_* calls.No. The bytes are const.
Calls can failCalls that may allocate return bool.Never. No call allocates.
NUL-terminatedYes. str_cstr is safe to pass to C APIs.Not in general. A slice ends mid-string.
Start asStr s = { 0 };SV("lit"), sv_from_cstr(p), str_view(&s), sv_slice(v, a, b)
Pass to functions asStr *. Never by value.StrView by value. It is small.
Valid untilstr_freeThe owner is freed or grows

Str on its own

Build, read, free

A zeroed Str is a valid empty string that owns nothing. The first append allocates. Every call that might allocate returns bool, and a failed call leaves the string as it was. That makes it safe to chain calls with &&: the chain stops at the first failure and nothing after it runs.

notes/str-examples/01_build.c

/* Str on its own: build a string, read it, free it. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    Str s = { 0 };

    /* A failed call leaves `s` unchanged, so && stops at the first failure. */
    bool ok = str_append_cstr(&s, "Rodion") &&
              str_push(&s, ' ') &&
              str_append(&s, SV("Raskolnikov")) &&
              str_appendf(&s, ", former student, age %d", 23);
    if (!ok) {
        str_free(&s);
        return EXIT_FAILURE;
    }

    printf("%.*s\n", STR_FMT(s));
    printf("len %zu, capacity %zu\n", s.len, s.capacity);
    puts(str_cstr(&s));

    str_free(&s);
    return EXIT_SUCCESS;
}
Rodion Raskolnikov, former student, age 23
len 42, capacity 60
Rodion Raskolnikov, former student, age 23

Two ways to print are shown. STR_FMT passes the length and the pointer to %.*s, so printf prints exactly len bytes. It works on a Str and on a StrView. str_cstr returns a NUL-terminated pointer for APIs like puts and fopen. It never returns null, even for an empty string.

The capacity is 60 because the buffer starts at 15 bytes and doubles when full: 15, 30, 60. Doubling means appending one byte at a time still costs a constant amount per byte on average.

Reuse one buffer

str_clear sets the length to zero and keeps the allocation. In a loop that builds a string per item, clear and refill one Str instead of creating and freeing one per pass.

notes/str-examples/02_reuse.c

/* Str on its own: clear and refill one buffer instead of allocating per line.
 */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    Str line = { 0 };
    for (int part = 1; part <= 3; part++) {
        str_clear(&line);
        if (!str_appendf(&line, "PART %d", part)) {
            str_free(&line);
            return EXIT_FAILURE;
        }
        printf("%-8s capacity %zu, data %p\n", str_cstr(&line), line.capacity,
               (void *)line.data);
    }
    str_free(&line);
    return EXIT_SUCCESS;
}
PART 1   capacity 15, data 0x6020000000f0
PART 2   capacity 15, data 0x6020000000f0
PART 3   capacity 15, data 0x6020000000f0

The address is the same on every pass. One allocation served all three strings.

Read a whole file

str_append_file reads until end of file. If s is empty it takes the read buffer over instead of copying it. On failure it leaves s unchanged, and ferror tells you whether the read failed or memory ran out. Check ferror before fclose, because the stream is gone after.

notes/str-examples/03_file.c

/* Str on its own: read a whole file into memory. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
    const char *path = argc > 1 ? argv[1] : "data/pg2554.txt";
    FILE *f = fopen(path, "rb");
    if (f == NULL) {
        perror(path);
        return EXIT_FAILURE;
    }

    Str text = { 0 };
    bool ok = str_append_file(&text, f);
    bool read_error = ferror(f);
    fclose(f);
    if (!ok) {
        fprintf(stderr, "%s: %s\n", path,
                read_error ? "read error" : "out of memory");
        return EXIT_FAILURE;
    }

    printf("%s: %zu bytes\n", path, text.len);
    str_free(&text);
    return EXIT_SUCCESS;
}
data/pg2554.txt: 1202773 bytes

StrView on its own

A view needs bytes that already exist. There are four ways to get one:

SV("text")
A string literal. The length is computed at compile time. Use it only on a literal, never on a pointer.
sv_from_cstr(p)
A NUL-terminated char * from a C API, such as argv[i] or getenv. It measures with strlen. Null gives an empty view.
str_view(&s)
The whole contents of a Str.
sv_slice(v, from, to)
Bytes from up to but not including to of another view. It asserts the range is inside v.

Inspect and slice

This parses a header line in the format Gutenberg uses. Trimming, finding and slicing all return new views into the same literal. Nothing is copied.

notes/str-examples/04_views.c

/* StrView on its own: inspect and slice text without allocating. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv) {
    StrView header = SV("  Title: Crime and Punishment\r\n");
    StrView line = sv_trim(header);
    printf("[%.*s]\n", STR_FMT(line));

    if (sv_starts_with(line, SV("Title:"))) {
        size_t colon = sv_find_byte(line, ':');
        StrView key = sv_slice(line, 0, colon);
        StrView value = sv_trim(sv_slice(line, colon + 1, line.len));
        printf("key [%.*s] value [%.*s]\n", STR_FMT(key), STR_FMT(value));

        /* Wrong: %s runs on to the next NUL, past the end of the view. */
        printf("%%s on key prints [%s]\n", key.data);
    }

    size_t at = sv_find(line, SV("Punishment"));
    if (at != SV_NPOS) printf("Punishment starts at byte %zu\n", at);
    printf("ends with \"ment\": %d\n", sv_ends_with(line, SV("ment")));

    /* argv strings already exist, so a view just borrows them. */
    for (int i = 1; i < argc; i++) {
        StrView arg = sv_from_cstr(argv[i]);
        if (str_eq(arg, SV("--verbose"))) puts("verbose on");
    }
    return EXIT_SUCCESS;
}
$ ./build/ex --verbose
[Title: Crime and Punishment]
key [Title] value [Crime and Punishment]
%s on key prints [Title: Crime and Punishment
]
Punishment starts at byte 17
ends with "ment": 1
verbose on

The third line shows the bug on purpose. key is five bytes long, but %s ignores the length and prints until it finds a NUL. Here that printed the rest of the line, including the \r\n. On a view with no NUL after it, the same mistake reads past the buffer. Always print a view with %.*s and STR_FMT.

The find functions return SV_NPOS when there is no match. Always compare against it before you use the index.

Split and tokenize

Both functions follow the same loop shape. You pass a view of the remaining text by pointer. Each call cuts one piece off the front, stores it in the output view, and returns false when nothing is left.

notes/str-examples/05_split.c

/* StrView on its own: split fields and tokens. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    /* Split keeps empty fields: the right tool for CSV-like data. */
    StrView rest = SV("Raskolnikov,Sonya,,Dunya,");
    StrView field;
    while (sv_split_next(&rest, SV(","), &field)) {
        printf("field [%.*s]\n", STR_FMT(field));
    }

    /* Token skips runs of delimiters: the right tool for words. */
    rest = SV("  Pale,  feverish   and  restless. ");
    StrView word;
    while (sv_token_next(&rest, SV(" ,."), &word)) {
        printf("word  [%.*s]\n", STR_FMT(word));
    }
    return EXIT_SUCCESS;
}
field [Raskolnikov]
field [Sonya]
field []
field [Dunya]
word  [Pale]
word  [feverish]
word  [and]
word  [restless]
sv_split_nextsv_token_next
SeparatorOne multi-byte string, such as "," or "\r\n"A set of single bytes, any of which separates
Empty piecesKept. a,,b gives a, empty, b.Skipped. Runs of delimiters count as one.
Trailing separatorNo empty piece after the last oneIgnored
Use forCSV fields, lines, key=valueWords, whitespace-separated input

sv_token_next is strtok without its problems. It does not write NULs into your text. It keeps no hidden state, so two loops can run at once.

Sort

A view is a plain struct, so an array of them sorts with qsort. sv_cmp orders bytes like memcmp, and a prefix sorts before the longer string.

notes/str-examples/06_sort.c

/* StrView on its own: sort an array of views with qsort. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

static int cmp_views(const void *a, const void *b) {
    return sv_cmp(*(const StrView *)a, *(const StrView *)b);
}

int main(void) {
    StrView names[] = { SV("Sonya"), SV("Dunya"), SV("Razumikhin"),
                        SV("Raskolnikov"), SV("Porfiry") };
    size_t n = sizeof names / sizeof names[0];

    qsort(names, n, sizeof names[0], cmp_views);
    for (size_t i = 0; i < n; i++) printf("%.*s\n", STR_FMT(names[i]));
    return EXIT_SUCCESS;
}
Dunya
Porfiry
Raskolnikov
Razumikhin
Sonya

Using them together

One owner, many views

This is the shape most text processing takes. One Str holds the whole file. Every line, word and match is a view into it. The program makes one allocation for the file and none for the 22,750 lines.

notes/str-examples/07_lines.c

/* Together: one Str owns the file, views walk its lines. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    FILE *f = fopen("data/pg2554.txt", "rb");
    if (f == NULL) {
        perror("data/pg2554.txt");
        return EXIT_FAILURE;
    }
    Str text = { 0 };
    bool ok = str_append_file(&text, f);
    fclose(f);
    if (!ok) return EXIT_FAILURE;

    size_t lines = 0, chapters = 0, mentions = 0;
    StrView rest = str_view(&text);
    StrView line;
    while (sv_split_next(&rest, SV("\n"), &line)) {
        line = sv_trim(line); /* the file uses \r\n; trim drops the \r */
        lines++;
        if (sv_starts_with(line, SV("Author:"))) {
            printf("%.*s\n", STR_FMT(line));
        }
        if (sv_starts_with(line, SV("CHAPTER "))) chapters++;

        /* Count every occurrence in the line by sliding past each match. */
        StrView scan = line;
        size_t at;
        while ((at = sv_find(scan, SV("Raskolnikov"))) != SV_NPOS) {
            mentions++;
            scan = sv_slice(scan, at + 1, scan.len);
        }
    }
    printf("%zu lines, %zu chapters, %zu mentions of Raskolnikov\n", lines,
           chapters, mentions);

    str_free(&text); /* every view into it dies here */
    return EXIT_SUCCESS;
}
Author: Fyodor Dostoyevsky
22750 lines, 78 chapters, 784 mentions of Raskolnikov

Two details matter. The file has Windows line endings, so each line split on "\n" still ends in \r. sv_trim removes it along with any other surrounding whitespace. Second, nothing writes to text while the loop runs. That is what keeps every view valid. The count of 784 agrees with grep -o Raskolnikov data/pg2554.txt | wc -l.

Copy when it must outlive the source

A view dies with its owner. When you need a piece of text after the owner is freed, copy it into a Str of its own. Here a struct keeps the title and author while the 1.2 MB file buffer is released.

notes/str-examples/08_keep.c

/* Together: copy a view into its own Str when it must outlive its source. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

typedef struct Book {
    Str title;
    Str author;
} Book;

static void book_free(Book *b) {
    str_free(&b->title);
    str_free(&b->author);
}

/* Fill `b` from a Gutenberg header. Views in, owned copies out. */
static bool book_parse(Book *b, StrView text) {
    StrView line;
    while (sv_split_next(&text, SV("\n"), &line)) {
        line = sv_trim(line);
        if (sv_starts_with(line, SV("Title: "))) {
            str_clear(&b->title);
            if (!str_append(&b->title, sv_slice(line, 7, line.len)))
                return false;
        } else if (sv_starts_with(line, SV("Author: "))) {
            str_clear(&b->author);
            if (!str_append(&b->author, sv_slice(line, 8, line.len)))
                return false;
        }
    }
    return true;
}

int main(void) {
    FILE *f = fopen("data/pg2554.txt", "rb");
    if (f == NULL) return EXIT_FAILURE;
    Str text = { 0 };
    bool ok = str_append_file(&text, f);
    fclose(f);
    if (!ok) return EXIT_FAILURE;

    Book book = { 0 };
    ok = book_parse(&book, str_view(&text));
    str_free(&text); /* the 1.2 MB buffer goes; the book keeps its copies */
    if (!ok) {
        book_free(&book);
        return EXIT_FAILURE;
    }

    printf("%s by %s\n", str_cstr(&book.title), str_cstr(&book.author));
    book_free(&book);
    return EXIT_SUCCESS;
}
Crime and Punishment by Fyodor Dostoyevsky

A struct that holds Str fields owns them. Give it a matching _free function that frees each field, like book_free. Zero-initialise the struct with = { 0 } so the free function is safe to call even if parsing stopped part way.

Writing your own functions

The library's own signatures are the pattern to copy. A function that only reads text takes a StrView. A function that produces text takes a Str * to append to and returns bool. The caller then decides where the output goes and whether to clear it first.

notes/str-examples/09_replace.c

/* Together: write your own function with views in and a Str out. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

/* Append `text` to `out` with every `from` replaced by `to`. */
static bool replace_all(Str *out, StrView text, StrView from, StrView to) {
    size_t at;
    while ((at = sv_find(text, from)) != SV_NPOS) {
        if (!str_append(out, sv_slice(text, 0, at)) || !str_append(out, to)) {
            return false;
        }
        text = sv_slice(text, at + from.len, text.len);
    }
    return str_append(out, text);
}

int main(void) {
    Str out = { 0 };
    if (!replace_all(&out, SV("Rodya went out. Rodya came back."), SV("Rodya"),
                     SV("Raskolnikov"))) {
        str_free(&out);
        return EXIT_FAILURE;
    }
    printf("%.*s\n", STR_FMT(out));

    /* Appending a Str to itself is safe: str_append handles the overlap. */
    if (!str_append(&out, str_view(&out))) {
        str_free(&out);
        return EXIT_FAILURE;
    }
    printf("%zu bytes after doubling\n", out.len);
    str_free(&out);
    return EXIT_SUCCESS;
}
Raskolnikov went out. Raskolnikov came back.
88 bytes after doubling
Your function needs toSignature shapeExample
Read textTake StrViewbool is_heading(StrView line)
Return part of its inputReturn StrView. No bool, it cannot fail.StrView first_word(StrView line)
Produce new textTake Str *out, append, return boolbool replace_all(Str *out, StrView text, ...)
Keep text in a structStr field and a _free functionBook and book_free

Taking a StrView makes a function accept every kind of string. A caller with a literal passes SV("..."). A caller with a Str passes str_view(&s). A caller with a char * passes sv_from_cstr(p). One function serves all three.

The last call in the example appends a string to itself. That is safe. str_append notices that the view points into its own buffer and recomputes the address after growing.

Mistakes and what they look like

A view kept across a reallocation

This is the mistake to understand first. Any call that may grow a Str can move its buffer, and every view into the old buffer then points at freed memory.

notes/str-examples/10_dangling.c

/* Bug on purpose: a view kept across a call that reallocates. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    Str s = { 0 };
    if (!str_append_cstr(&s, "Sonya")) return EXIT_FAILURE;
    StrView name = str_view(&s);

    /* 15 bytes fit; this append needs more, so the buffer moves. */
    if (!str_append_cstr(&s, " Semyonovna Marmeladova")) return EXIT_FAILURE;

    printf("%.*s\n", STR_FMT(name)); /* reads freed memory */
    str_free(&s);
    return EXIT_SUCCESS;
}
ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000f0
READ of size 1 at 0x6020000000f0 thread T0
    #5 in main 10_dangling.c:15
freed by thread T0 here:
    #0 in realloc
    #1 in str_grow_ str.c:25
    #4 in str_append_cstr str.c:114
    #5 in main 10_dangling.c:13
previously allocated by thread T0 here:
    #0 in realloc
    #5 in main 10_dangling.c:9

The report is trimmed to the useful frames. Read it bottom up. Line 9 allocated the buffer. Line 13 grew it, and realloc freed the old one. Line 15 read through the stale view. There are two fixes. Take the view after the last call that grows the string. Or call str_reserve for the full size first, so the appends fit and the buffer never moves.

Copying a Str struct

Str b = a; copies the pointer, not the bytes. Now two structs think they own one buffer, and freeing both is a double free.

notes/str-examples/11_double_free.c

/* Bug on purpose: copying a Str struct makes two owners of one buffer. */
#include "mem/mem.c"
#include "str/str.c"

#include <stdlib.h>

int main(void) {
    Str a = { 0 };
    if (!str_append_cstr(&a, "Dunya")) return EXIT_FAILURE;
    Str b = a; /* copies the pointer, not the bytes */
    str_free(&a);
    str_free(&b); /* frees the same buffer again */
    return EXIT_SUCCESS;
}
ERROR: AddressSanitizer: attempting double-free on 0x6020000000f0 in thread T0:
    #1 in str_free str.c:49
    #2 in main 11_double_free.c:11
freed by thread T0 here:
    #1 in str_free str.c:49
    #2 in main 11_double_free.c:10

To duplicate a string, make a real copy with str_from(&b, str_view(&a)). To hand ownership over, copy the struct and then reset the source with a = (Str){ 0 }; so only one owner remains. Pass a Str to functions as Str * so this never happens by accident.

Smaller traps

MistakeWhat happensDo this instead
printf("%s", v.data) on a viewPrints past the end of the view, as shown in Inspect and sliceprintf("%.*s", STR_FMT(v))
SV(p) where p is a char *A compile error, expected '}', because SV only takes a literal. Before 0.0.1 it compiled and silently used sizeof(char *) - 1 as the length.sv_from_cstr(p)
Ignoring the boolwarning: ignoring return value of function declared with 'warn_unused_result' attributeCheck it, or chain the calls with &&
Passing s.data to a C APINull for an empty Str, which many APIs crash onstr_cstr(&s), which never returns null
Forgetting str_freeA leak. AddressSanitizer on macOS does not report leaks, so nothing warns you.Write the str_free as soon as you write the Str s = { 0 };

Which function for which job

I want toUse
Build text piece by piecestr_append, str_append_cstr, str_push, str_appendf
Start with a copy of some textstr_from, str_from_cstr
Read a whole filestr_append_file
Avoid repeated growth when I know the sizestr_with_capacity, str_reserve
Empty a string but keep its memorystr_clear
Give back unused capacitystr_shrink_to_fit
Release the memorystr_free
Pass a literal where a view is wantedSV("...")
Wrap a char * from a C APIsv_from_cstr
Hand my Str to code that only readsstr_view(&s)
Hand my Str to a C APIstr_cstr(&s)
Print either typeprintf("%.*s", STR_FMT(x))
Comparestr_eq for equality, sv_cmp for order
Searchsv_find_byte, sv_find, sv_starts_with, sv_ends_with
Cut out a piecesv_slice, sv_trim
Walk fields or linessv_split_next
Walk wordssv_token_next
Keep a piece after its source is freedstr_from(&copy, view)

Exercises

Each one starts from an example file. Keep the sanitizer on and check the output against what you expect.