Why Exercises Do Not Turn Into Projects

Most C tutorials end with a single file of fifty lines that prints a result and exits. You finish a dozen of them and still feel stuck the moment someone says "build something real."

The gap is not more syntax. A real project differs from an exercise in four ways: it is split across several files, it keeps data in a structure instead of loose variables, it saves that data so it survives a restart, and it can be built with one command. This post shows how to build real-world projects in C, starting with a small command-line task manager that adds, lists, completes and deletes tasks.

By the end you will have a program you can actually use, in about 200 lines of C, and a layout you can copy for your next project.

Let's create our own real-world projects in C

Project 1: Task manager CLI

Goal: A command-line to-do list written in C that adds, lists, completes and deletes tasks, and saves them to a file between runs.

What you need

  • A C compiler (gcc or cl.exe) and VS Code

  • A project folder with three source files: main.c, tasks.h and tasks.c

  • A text file, tasks.txt, created by the program as storage

  • C concepts: structs, pointers, malloc/realloc/free, strings, file I/O and command-line arguments

Steps

Step 1: Define requirements List the commands the tool must support: add, list, done and delete. Decide what happens on bad input, such as no command, an unknown command or a task number that doesn't exist. Writing this down first keeps the scope fixed.

Step 2: Set up the project structure Create the folder and the three files, and add a README. Decide the roles up front. main.c handles user input and output. tasks.c and tasks.h handle the task list logic. Keeping those two jobs apart is the main design decision of the project.

Step 3: Design the data model Decide what one task contains: its text and a done flag. Decide what the list contains: the tasks, how many there are, and how much room is allocated. Choose a growable array so the list can hold any number of tasks.

Step 4: Write the header file In tasks.h, declare the two data types and the functions the list needs (initialize, free, add, remove, load and save). Add include guards. This file is the contract between main.c and tasks.c.

Step 5: Implement memory management In tasks.c, write the initialize and free functions first. Decide who owns each piece of memory, and make sure every allocation has a matching release. Do this before anything else, because memory bugs are hardest to fix late. Uninitialised memory is also what causes garbage values in C.

Step 6: Implement add and remove Write the function that adds a task. It must grow the array when it is full and store its own copy of the text. Then write the remove function, which must free the removed text and close the gap in the array. Check allocation failures and invalid positions in both.

Step 7: Design and implement file storage Choose a simple, readable format, such as one line per task with the done flag first and the text after it. Write save, then load. Decide how load handles a missing file (treat it as an empty list) and how it handles damaged lines (skip them). Read the file line by line with fgets; scanf vs fgets in C explains why it is safer than scanf.

Step 8: Build the command-line interface In main.c, check the number of arguments, load the list and pick the action from the first argument. Show a clear usage message for any invalid input. Return a non-zero exit code on errors, as real command-line tools do.

Step 9: Implement the commands one at a time Add list first, because it shows whether loading works. Then add, then done, then delete. After each one, build and run it before moving on. For the commands that take a number, validate it fully, including text that isn't a number and numbers out of range.

Step 10: Compile with strict warnings and test Build with all warnings turned on and fix every one. Test the normal paths and the edge cases: an empty list, a long task text, a missing data file, task 0, task 999 and a non-numeric task number. Confirm that tasks persist between runs.

Step 11: Check memory and robustness Run a memory checker (valgrind in WSL, or the address sanitizer if your compiler supports it) to confirm there are no leaks or invalid accesses. Review what happens if the text contains a line break, since that would break the file format, and decide how to handle it.

Step 12: Document and publish Write the README with a project description, build instructions, usage examples and the storage format. Add tasks.txt and build outputs to .gitignore. Commit the work in clear, small commits. Then write the blog post, explaining your design decisions rather than just the code.

tasks.c (full source)

#include "tasks.h" #include <stdio.h> #include <stdlib.h> #include <string.h>  void tasklist_init(TaskList *list) { list->items = NULL; list->count = 0; list->capacity = 0; } void tasklist_free(TaskList *list) { for (size_t i = 0; i < list->count; i++) { free(list->items[i].text); } free(list->items); tasklist_init(list); } int tasklist_add(TaskList *list, const char *text) { if (list->count == list->capacity) { size_t new_capacity = list->capacity ? list->capacity * 2 : 8; Task *grown = realloc(list->items, new_capacity * sizeof(Task)); if (grown == NULL) { return 1; } list->items = grown; list->capacity = new_capacity; } char *copy = malloc(strlen(text) + 1); if (copy == NULL) { return 1; } strcpy(copy, text); list->items[list->count].text = copy; list->items[list->count].done = 0; list->count++; return 0; } int tasklist_remove(TaskList *list, size_t index) { if (index >= list->count) { return 1; } free(list->items[index].text); for (size_t i = index; i + 1 < list->count; i++) { list->items[i] = list->items[i + 1]; } list->count--; return 0; } /* File format: one task per line, "<0|1>|<text>". */ int tasklist_load(TaskList *list, const char *path) { FILE *file = fopen(path, "r"); if (file == NULL) { return 0; /* no file yet: empty list */ } char line[1024]; while (fgets(line, sizeof line, file)) { line[strcspn(line, "\r\n")] = '\0'; if ((line[0] != '0' && line[0] != '1') || line[1] != '|') { continue; } if (tasklist_add(list, line + 2) != 0) { fclose(file); return 1; } list->items[list->count - 1].done = line[0] == '1'; } fclose(file); return 0; } int tasklist_save(const TaskList *list, const char *path) { FILE *file = fopen(path, "w"); if (file == NULL) { return 1; } for (size_t i = 0; i < list->count; i++) { fprintf(file, "%d|%s\n", list->items[i].done ? 1 : 0, list->items[i].text); } return fclose(file) == 0 ? 0 : 1; } 

This file is the engine of the project. It manages a growable list of tasks in memory and saves and loads it from a file. main.c only calls these functions.

tasklist_init

list->items = NULL; list->count = 0; list->capacity = 0; 

This starts an empty list. -> means "the field of the struct this pointer points to". NULL means no memory has been allocated yet.

tasklist_add (the most important function)

if (list->count == list->capacity) { size_t new_capacity = list->capacity ? list->capacity * 2 : 8; Task *grown = realloc(list->items, new_capacity * sizeof(Task)); 

When the array is full, it grows. The first time it makes room for 8 tasks, and after that it doubles. realloc resizes the memory block and keeps the existing tasks. Doubling means growth is rare.

if (grown == NULL) return 1; 

realloc can fail. The result goes into a temporary pointer first, so a failure doesn't lose the original array.

char *copy = malloc(strlen(text) + 1); strcpy(copy, text); 

This makes the list's own copy of the text. The + 1 leaves room for the '\0' that ends a C string. A copy is needed because the original text (such as argv[2]) isn't owned by the list.

list->items[list->count].text = copy; list->count++; 

Store the new task at the next free slot, then increase the count.

tasklist_remove

free(list->items[index].text); for (...) list->items[i] = list->items[i + 1]; list->count--; 

This frees the removed task's text, then shifts every later task down one slot to close the gap. The count shrinks, but the array itself stays the same size.

tasklist_free

Free each task's text first, then the array. Both were allocated with malloc/realloc, so both must be freed. Skipping this causes a memory leak. At the end it resets the list to empty so it can't be used with dangling pointers.

tasklist_load

FILE *file = fopen(path, "r"); if (file == NULL) return 0; 

A missing file is normal on the first run, so it counts as an empty list, not an error.

while (fgets(line, sizeof line, file)) { line[strcspn(line, "\r\n")] = '\0'; 

fgets reads one line at a time. strcspn finds the position of the line break, and writing '\0' there removes it.

if ((line[0] != '0' && line[0] != '1') || line[1] != '|') continue; 

Skip any line that doesn't match the 0|text format, so a damaged line can't crash the program.

tasklist_add(list, line + 2); list->items[list->count - 1].done = line[0] == '1'; 

line + 2 skips the 0| prefix to give just the text. The task is added, and then its done flag is set from the first character.

tasklist_save

FILE *file = fopen(path, "w"); fprintf(file, "%d|%s\n", done ? 1 : 0, text); return fclose(file) == 0 ? 0 : 1; 

Open the file for writing, which replaces the old contents, and write one line per task. Checking the result of fclose catches write errors, such as a full disk.

Ideas to remember

Growable array: capacity doubles when full, which is the same idea behind dynamic arrays in other languages.

Ownership: every malloc needs one free, and the list owns its copies of the text.

Return codes: 0 means success and 1 means failure, so main.c can check each call.

Stretch goals

  • Task priorities or due dates

  • An edit command

  • A clear-done command that removes all finished tasks

  • Sorting and filtering in list

  • Unit tests for the list functions

Terminal commands

First, open a new terminal in VS Code (Ctrl+Shift+`, not the debug terminal) and go to the project folder:

`shell
cd C-Programing\Real-World_Projects

`

Build (once, and again after you change the code):

`shell
gcc -g -Wall -Wextra main.c tasks.c -o tasks.exe

`

ActionCommandAdd a task.\tasks.exe add "buy milk"Show all tasks.\tasks.exe listMark task 1 as done.\tasks.exe done 1Delete task 1.\tasks.exe delete 1

Notes:

  • Put task text with spaces in quotes.

  • done and delete take the number from list, not the text.

  • After a delete, the numbers shift, so run list again before deleting another.

  • The program name is tasks.exe, with an "s". .\ is required in PowerShell.

  • To start fresh, delete tasks.txt.

Example tasks.txt

`plaintext
1|Buy milk
0|Call mom

`

The Pattern to Remember

Every real C project reuses the same ideas: small files with one job each, structs for your data, memory that grows when needed, return codes you actually check, and a build you can repeat with one command. Swap the task list for a contact book or a log parser and the structure stays the same.

Pick a project small enough to finish this week, and build it using this layout. New to C? Start with Introduction to C programming and User Input in C: A Complete Guide for Beginners.

Download the code: the full task manager source is on GitHub in C-Programing/Real-World_Projects. Compile it with gcc -Wall -Wextra and run it yourself.

Originally published on my blog. I post new C, Python and AI-agent guides there regularly.