C Strings: A 50-Year Mistake
Why C strings suck—and why length-based strings are the right design.
One design choice in C that now feels outdated—and arguably one of its biggest mistakes—is the use of null-terminated strings.1 Treating strings as pointers to character “streams” and relying on a terminating NULL character may have made more sense in the 1970s due to the memory and performance constraints of the time, but nowadays there’s basically no reason to keep using it.
The correct modern choice adopted by most newer languages and popular frameworks is length-based strings—basically, a struct with a pointer and a size.
struct String
{
u8* data;
u64 size;
};
#define Str(s) (String){ (u8*)(s), sizeof(s) - 1 }
#define SArg(s) (int)((s).size), ((s).data)
bool StrCmp(String a, String b);
// ...
// Somewhere in code
String a = Str("hello");
String b = Str("world");
if (!StrCmp(a, b))
printf("a is %.*s and b is %.*s\n", SArg(a), SArg(b));But why is this that much better, though?
1. Losing valuable information
A string’s length is an extremely valuable piece of information. By not storing it, you force all code that uses the string to either repeatedly call strlen or iterate over it byte by byte. Both approaches are inefficient. Both approaches lead to worse overall code.
The code is also extremely error-prone, since checking and enforcing the length at runtime becomes much harder, slower, and more cumbersome. Debugging also suffers, since communicating this valuable information to other tools and analyzers becomes a nightmare (e.g., viewing a char* versus a char[N] versus a char*[N] is handled inconsistently across tooling).
I personally believe that many of C’s memory-related issues and overflow bugs stem largely from this outdated design choice.2 Others may disagree, arguing that a sentinel is sufficient in most cases, making it unnecessary to store a string's length. However, without knowing the length, strings become more like linked lists, with a serial access pattern rather than the random access of an array.
Proponents of C strings often defend this consequence by arguing that such an iterative style is actually preferable because it encourages single-pass algorithms over multi-pass traversals, under the assumption that fewer passes over a string are inherently more efficient.
What they're missing is that most programmers, especially beginners, do not naturally think in those terms. They typically write code that assumes the string length is cheaply available. I would also argue that most common string routines are more naturally expressed this way. As a result, many unnecessary multiple passes over the same string in modern programs are caused by repeated calls to strlen. Furthermore, even aside from that, the assumption that fewer passes are always better is simply incorrect and comes from a misunderstanding of how modern CPUs work.
There are some advantages to enforcing a sentinel-based design though, and we will examine them in greater detail later.
2. String lengths now have different meanings
One of the most commonly used standard library functions in C is probably snprintf. To refresh your memory, here’s its prototype:
int snprintf(char *str, size_t size, const char *format, ...);Ok, can you tell me what the integer it takes and the integer it returns are for?
If you’re inexperienced in C and not paying too much attention—maybe just skimming through documentation, reading example code, or recalling something you heard before—you might answer something similar to: snprintf takes the size of the output buffer as an argument and returns the length of the string it would have produced.
But once you actually sit down and use it, that answer is no longer sufficient. You start wondering: what does “length” actually mean here?3 Does it include the null terminator, or not?
And that is the gist of the problem. By requiring every string to end with a null byte, the concept of “string length” becomes subtly ambiguous in practice. This means that whenever you work with4 a string-related function—whether in the standard library, a large production codebase, or some third-party libraries—you constantly have to ask yourself a series of questions: Does this function write the null terminator for me? Do I need to allocate space for the string plus the null byte? And does the size argument—or the return value—include or exclude it?
What’s even worse is that the C standard library is not entirely consistent in how it defines “length”, which makes it even harder to guess which one is which reliably. snprintf is a good example of this: the buffer and its size must account for the null terminator. Many people correctly infer this and naturally assume that the return value follows the same rule. But they would be wrong. In reality, the two behave in opposite ways: the input integer includes the null terminator, while the output integer excludes it.
On the other hand, the expression sizeof(str), which might look like a string-related function to a beginner, especially with a name that also starts with s and ends with f, and usually appears in contexts where you’re trying to get the “size” of something—returns a size that does include the null terminator.
But the pain does not stop there. Oh no, no, no, my sweet summer child. Imagine you're writing some quick throwaway code:
#define TEST_STRING "some string"
size_t size = sizeof(TEST_STRING) - 1;And then you want to get serious and refactor the test string into a runtime string:
const char *str = "some string"
size_t size = strlen(str) - 1;You now have a bug, since strlen returns the length excluding the null terminator.
Fucking marvellous.
3. Unnecessary Work
I don't know this for certain, but I suspect part of the reason snprintf returns a length that excludes the null terminator is to support a pattern like this:
int offset = 0;
offset += snprintf(ptr + offset, size - offset, "%d", my_int);
offset += snprintf(ptr + offset, size - offset, "%s", my_str);
offset += snprintf(ptr + offset, size - offset, "%f", my_flt);
// ...You're probably thinking: wait, doesn't this mean you're constantly overwriting the previous null terminator? In other words, aren't all those intermediate terminators unnecessary?
And you'd be right. It's unnecessary and, in theory, might be a bit slower, but I doubt the performance impact is meaningful. What I think is far worse is the confusion it creates. Since most people wouldn’t expect code to do something unnecessary constantly, it ends up making them believe that snprintf doesn’t null-terminate the string. This is why you often see people adding an extra ptr[offset] = 0; at the end. This is, again, unnecessary, and, again, creates even more confusion, reinforcing the mistaken belief among beginners that snprintf doesn’t null-terminate the string.
4. Invalid Strings
Having two different meanings for a string's length also gives you two distinct representations of an invalid string: an empty string and a null string. As a result, every piece of code that processes strings at runtime must constantly check for both cases.5
A similar issue arises with arrays. By decaying arrays into pointers, you discard valuable information, such as their length. Code that previously worked without special handling now becomes invalid when the array is null. A common example in C# is iterating over an array or a string. Since both are reference types, attempting to access their Length property or iterate over them will throw an exception if the reference is null, requiring additional null checks before use.
// This doesn't work when 'str' is null.
for (int i = 0; i < str.Lenght; ++i)
// Instead, you have to do this ridiculous and ugly dance.
for (int i = 0; i < str?.Length ?? 0; ++i)If, instead, you store and pass strings around as a simple struct containing a pointer and a length, the previous examples work without any issue.6 There is now only one correct way to test whether a string is empty: check whether its length is 0. The pointer itself may be either null or non-null, since there are legitimate cases where an empty string still has a valid pointer7—for example, when a string has been advanced to its end, or when it refers to an empty substring within a larger string.
5. Arbitrary Binary Data
Another advantage of switching to length-based strings is that the same type can be reused for byte arrays. Since all code now operates on an explicit length rather than relying on a null terminator, the type can safely store arbitrary binary data, including values that contain null bytes. And just as most ASCII string functions still work with UTF-8, these length-based string operations also apply naturally to arbitrary binary data: scanning, splitting, trimming, slicing, as well as other string manipulations.
And since we’re talking about binary data, null-terminated strings still bring back the same issue of having two different meanings for length. You are forced to decide whether to store the null byte. Storing it makes both the writer and reader code simpler and closer to normal usage patterns. Not storing it saves space and reduces handling overhead, which is often one of the main reasons for using a binary format in the first place.8
Obviously, always using length-based strings avoids all of these problems and sources of confusion, and removes the need to make any of these hard choices in the first place.
6. Efficient Substrings
And finally, perhaps the most important advantage of all: efficient substrings. This is by far the greatest advantage of length-based strings over C strings, outweighing all the previous ones combined.
By requiring every string to end with a null terminator, you force all common string operations—such as trimming, slicing, splitting, tokenizing, and searching—to allocate new strings, often along with multiple intermediate allocations and copies.
This also means that when strings are no longer required to have a null terminator, all of the above operations can simply return a new string that references a portion of the original string, avoiding allocations, copies, and any extra memory management.
String StrPrefix(String str, u64 size);
String StrPostfix(String str, u64 size);
String StrChop(String str, u64 size);
String StrSkip(String str, u64 size);
String Substr(String a, u64 min, u64 max);
String StrFindNeedle(String str, String needle);
String StrTrim(String str);Writing lexers and parsers for common file formats such as CSV, Markdown, JSON, and C becomes so much simpler and more efficient. Rather than allocating and copying every token, the names and values stored in the resulting parse tree can simply reference slices of the original input, and any code that consumes the tree can continue to operate on those same slices without additional memory management.
The Downsides
A sentinel can be a useful tool in your programming toolbox for enforcing invariants and may provide some performance benefits. However, with advances in modern hardware and compilers, the performance argument is largely outdated (outside of niche cases). Reducing program invariants, on the other hand, is essentially the only remaining advantage of C strings.
A common example of this is hand-coded token checking. With a sentinel, null-terminated strings can safely check the current byte in a loop at any time. Even better, they can perform lookaheads and check for sequences of bytes, with the guarantee that order is preserved, allowing short-circuit evaluation to fail early and skip the rest of the expression (e.g., s[i] == 'f' && s[i+1] == 'o' && s[i+2] == 'r' will always work).
On the other hand, length-based strings make these checks slightly more annoying, since ending a token because you hit whitespace is different from reaching the end of the input. Lookahead logic also becomes slightly more complicated and requires a more systemic approach.
Another disadvantage people often raise is that, since C uses null-terminated strings, most popular libraries and OS APIs require them.9 This is true, but it is usually not that significant in practice—especially on Windows, where you already have to convert UTF-8 strings to UTF-16. In that context, the additional cost of appending a null byte is relatively small.
Implementation
How to implement such a string layer well, with a thoughtfully designed API and well-placed abstractions, is beyond the scope of this article. I may write a more in-depth follow-up in the future exploring the different design trade-offs, discussing the various considerations involved, and sharing tips I've learned along the way, as well as helpers I've found useful in my experience. You could also check out the Resources section at the end in the meantime.
In the meantime, you could check out these resources to get a rough idea. Some of them actually inspired me to start using these lengthy strings.
You can take a look at Ryan Fleury’s RAD Debugger, which defaults to length-based strings backed by arenas throughout the project. Related to that, his Metadesk library is also a good example of how a standalone parsing library uses this approach.
Allen Webster also has an excellent series on building a base layer for a codebase, including an episode dedicated specifically to strings.
VoxelRifts’s video about his journey of learning C also touches on this.
Chris Wellons has been using this approach exclusively for some time now, and has also been experimenting with it in C++.
The previously linked
strlcpyarticle by NRK also includes an excellent addendum at the end.As usual, you can take a look at my barely active repo, which I mainly use for experimentation.
But before moving on, I want to touch on a core architectural decision when designing your string system: immutability. This should be a fundamental rule guaranteed by most APIs throughout your codebase. Once a string has been constructed, it must not be mutated, and its contents can be treated as constant. Any function that takes strings and returns strings must respect and preserve this property.
Note that these guarantees apply only at the function signature and API boundary levels. Inside a function, you can freely poke around, mutate things in place, or make any crazy modifications you need. This gives you all the advantages from immutability in functional programming at a higher, information-flow level while still retaining the full flexibility and power of procedural programming at a lower, implementation-level.
What’s interesting about this kind of guarantee is that it is primarily a coding and API convention, rather than something enforced by the language or checked at runtime. One might assume this makes it less safe, and that the cognitive overhead of constantly tracking these constraints mentally would make the code more error-prone and harder to write.
But the opposite is often true. In practice, upholding these constraints is not particularly difficult, especially when there is a clear distinction between different string types and operations. By not relying on the language to enforce them, you also free yourself from the control of some random language committee, gaining even more control over your codebase in the process. You can decide when to relax or enforce the rules, and at what level of granularity. Remember: languages are just APIs.
Other Implementations
Before we wrap up, let's look at some other popular approaches to implementing strings.
1. Flexible Array Member
This is the approach Redis uses. The idea is to store the maximum capacity and current length at the beginning of the structure, then declare the final field as a flexible array member to hold the string data itself.
The drawbacks of this approach are:
Non-compatible with string literals. Consider how you would declare one using this representation and how it would be used in practice.
No support for efficient substrings, which is one of the primary advantages of length-based strings.
2. Stretchy Buffer
Another way to implement this is with a stretchy buffer. You can think of these strings as dynamic-array-like, since stretchy buffers are commonly used for that purpose. The main difference from the previous approach is that the header (length and capacity) is stored separately rather than being explicitly part of the structure.
This has all of the previous disadvantages, plus two additional ones:
It "colors" every
char*with something mostly invisible.10 Unlike hidden headers placed beforemalloc-ed pointers, you can’t ignore these since they are intended to be part of the API model—you are expected to access and use them for virtually all meaningful string operations.Storing an extra capacity field, while understandable for a dynamic array, is a poor design choice for strings.11 It conflates multiple concepts into a single string type, with no clear distinction between them.
3. Pascal-style
Pascal-style strings, or short strings, as the name suggests, store strings in a fixed-size character array, typically 256 bytes in size. The length can be represented in two different ways: either by reserving a byte for the length (as Pascal does), or by using null termination.
This approach may have made sense in the past, when there were real memory and hardware constraints. Nowadays, with a good memory allocation strategy, it doesn’t make any sense except in very specific contexts.
Conclusion
And with that, we've finally reached the finish line. Thanks for sticking with me to the end.
I know this blog post is quite long, but the main reason I'm writing it is because I've always thought that just how much better length-based strings are was common knowledge. Every good programmer I look up to knows this, and so does the kind of community I've been part of, so I naturally assumed everyone else did too.
However, a recent Twitter discussion made me realize that many people still have never considered implementing strings this way. There are plenty of resources on the topic, but most of them are scattered across the internet, and I haven't seen anyone lay out the entire case in a single, centralized article.
I hope this post does exactly that, and if you enjoyed it, consider subscribing for more.
-Trần Thành Long
You may have also heard of other names such as ASCIIZ string, Z string, C string, NULL string, Zero string, Null-terminated byte strings (NTBS), etc. I will use these interchangeably from now on.
The same thing happens with arrays. Rather than making them first-class citizens and copy-by-value, C decays them into pointers, losing an enormous amount of information in the process, which causes them all the same problems as strings.
Don’t lie to me; I know every single C programmer has asked that same question at least once in their life.
Not only when using the function, but also when reading/understanding the code later.
A common way C# programmers cope with this is by always passing in and returning empty arrays/strings. The Clean Code™ cult also makes it one of their rules in Error Handling: Don’t Return Null and Don’t Pass Null, while going around screaming about the Billion Dollar Mistake without much understanding of the underlying causes or solutions.
Or even an invalid pointer, such as one that points to already-freed memory. This is still safe, provided all code checks the length before dereferencing the pointer.
What’s even worse—and something I’ve seen in multiple codebases—is storing both the string length and the string content, and then still appending a null byte. Every time I come across formats like this, I find myself wondering: does the stored length include the null terminator or not?
I think this is also a form of the sunk cost fallacy, along with familiar biases. Obviously, if the OS APIs require strings to be handled in a certain way, then there isn't much choice. However, I think people tend to overestimate the constraints imposed by OSes and vendors while underestimating the benefits of “lengthy” strings. And if the goal is for the ecosystem to adopt this approach, shouldn't we be actively pushing for it rather than simply accepting the status quo?
This is actually why Sean chose to implement it this way. He wanted strings to be represented as pointers, which provides type safety while avoiding the need to write .str or ->str. In my view, however, this is not a compelling enough benefit for either strings or dynamic arrays, especially given the drawbacks.
The FAM approach would also have this problem, but since you can use the capacity as the initial fixed size and avoid dynamic resizing altogether, I chose to mention it here instead. After all, the whole point of a stretchy buffer is… well… being “stretchy”.





I had always wondered why they weren't done this way in the first place. I mean, I get it, every byte counted, but how many really short strings did your program have such that an extra few bytes made or broke your memory budget? And meanwhile every clock cycle also counted, and you're using a lot of them to do things that you have to do a lot, or else you're just allocating an int to store and reuse the length value anyway...
Anyway maybe I was basking in the luxury of multi-megabyte(!) RAM but my very first question was "why doesn't it just store the length".