Building strings by hand
Working with text12 min readLesson 3 of 3
3.1 Two numbers describe a buffer
Building text in C means writing bytes into an array and keeping the terminator honest. Two values make that manageable: capacity, the bytes available, and used, the bytes already written before the terminator.
The invariant is used < capacity, and buffer[used] == '\0'. Every append checks that there is room for the new text plus its terminator.
Passing both values to a helper keeps the checks in one place. The helper returns the new used count, so the caller does not repeat the arithmetic.
#include <stdio.h>
#include <string.h>
static size_t append(char *buffer, size_t capacity, size_t used, const char *text)
{
size_t needed = strlen(text) + 1;
if (capacity - used < needed) return used;
memcpy(buffer + used, text, needed);
return used + needed - 1;
}
int main(void)
{
char buffer[32] = "";
size_t used = 0;
used = append(buffer, sizeof buffer, used, "Hello");
used = append(buffer, sizeof buffer, used, ", ");
used = append(buffer, sizeof buffer, used, "world");
printf("%s (%zu)\n", buffer, used);
return 0;
}Output
Hello, world (12)
3.2 The terminator has to be copied too
A copy loop that stops before the terminator leaves a buffer that prints whatever followed it. The terminator is part of the string, so a copy loop writes one more byte than the visible characters.
When the destination is smaller than the source, the last byte must be set to '\0' explicitly. Truncation happens at capacity - 1 bytes, not at capacity.
strlen counts the bytes before the terminator, so adding one gives the size to allocate or copy.
#include <stdio.h>
static void copy_bounded(char *dest, size_t capacity, const char *src)
{
size_t i = 0;
while (i + 1 < capacity && src[i] != '\0') {
dest[i] = src[i];
i++;
}
dest[i] = '\0';
}
int main(void)
{
char small[4], exact[6];
copy_bounded(small, sizeof small, "abcdefgh");
copy_bounded(exact, sizeof exact, "abcde");
printf("[%s] %zu\n", small, sizeof small);
printf("[%s] %zu\n", exact, sizeof exact);
return 0;
}Output
[abc] 4 [abcde] 6
3.3 Successive snprintf calls
snprintf can write into a buffer at an offset, which makes it a building block for formatted text. Each call gets the remaining capacity and returns how much of it the full output would need.
The return value can exceed the remaining capacity. Clamp it before adding: if the return is greater than or equal to the room, the buffer is full and the used count stops at capacity - 1.
This pattern handles mixed content, such as a label, a number, and punctuation, without manual number formatting.
#include <stdio.h>
int main(void)
{
char record[48];
int used = 0;
int capacity = (int)sizeof record;
used += snprintf(record + used, (size_t)(capacity - used), "id=%d", 7);
used += snprintf(record + used, (size_t)(capacity - used), ", name=%s", "Ada");
used += snprintf(record + used, (size_t)(capacity - used), ", score=%d", 91);
printf("%s (%d bytes)\n", record, used);
return 0;
}Output
id=7, name=Ada, score=91 (24 bytes)
3.4 Joining words with a separator
The separator belongs between items, not after the last one. A loop that adds ", " before every item except the first keeps the output clean and avoids a trailing comma.
That conditional is the whole trick, and it works for any number of items. Starting the used count at zero also handles an empty list with no special case.
With a capacity check inside the loop, a long list stops at an item boundary instead of being cut in the middle of a word.
#include <stdio.h>
#include <string.h>
int main(void)
{
const char *items[] = {"salt", "pepper", "cumin"};
char buffer[64] = "";
size_t used = 0;
for (int i = 0; i < 3; i++) {
const char *sep = (i == 0) ? "" : ", ";
int written = snprintf(buffer + used, sizeof buffer - used, "%s%s", sep, items[i]);
if (written < 0 || (size_t)written >= sizeof buffer - used) break;
used += (size_t)written;
}
printf("%s (%zu)\n", buffer, used);
return 0;
}Output
salt, pepper, cumin (19)
3.5 An integer to text without printf
Formatting a number by hand shows what %d does internally. Take the last digit with % 10, convert it to a character, then divide by 10 and repeat.
Digits come out in reverse order, so the loop writes into a temporary array backwards or reverses the result afterwards. This example writes the digits from the end of a small buffer toward the start.
The do loop runs once for zero, which a while (value != 0) loop would skip. Negative numbers need a sign and an unsigned magnitude, because negating INT_MIN overflows.
#include <stdio.h>
static void format_unsigned(char *out, unsigned int value)
{
char digits[12];
int count = 0;
do {
digits[count++] = (char)('0' + value % 10);
value /= 10;
} while (value != 0);
for (int i = 0; i < count; i++) out[i] = digits[count - 1 - i];
out[count] = '\0';
}
int main(void)
{
char text[16];
format_unsigned(text, 9042);
printf("%s\n", text);
return 0;
}Output
9042
3.6 Where manual building goes wrong
- forgetting the terminator after a copy loop
- checking
used < capacitywhen the new text needsused + length + 1 - subtracting a larger size from a smaller one in
size_t, which wraps to a huge number - calling
strlenon the whole buffer inside a loop, which turns a linear build into a quadratic one - treating the return of
snprintfas the number of bytes actually written
The unsigned subtraction bug is the hardest to spot: capacity - used underflows when used has already passed capacity, and the check that follows approves an enormous write.
Guard the subtraction with a comparison before it, or keep a single helper that owns every write into the buffer.
#include <stdio.h>
int main(void)
{
size_t capacity = 10;
size_t used = 14; /* pretend a bug let this happen */
size_t room = (used > capacity) ? 0 : capacity - used;
printf("room: %zu\n", room);
printf("guard needed: %d\n", used > capacity);
return 0;
}Output
room: 0 guard needed: 1
3.7 A small builder worth keeping
A struct Text that holds a pointer, a capacity, and a used count turns these rules into one object. Appending becomes a function that returns a status, and the caller stops tracking offsets.
That structure appears when records are read from files. Keeping the terminator and the count in agreement is easier when only two functions ever write to the buffer.
For fixed buffers, a plain array and a used count are enough. The struct becomes useful when the buffer is heap allocated and its capacity can change.
