The Quiet Terminal

← Beginner

Project: a command-line to-do list from scratch

2026-09-16 · 3 min read

Everything in the Beginner section — strings, dicts, loops, files, try/except — is meant to add up to something you built yourself. This is that something: a to-do list that runs in the terminal, remembers your tasks between runs, and comes in under sixty lines. I'll show the pieces, then the whole script.

The plan, and the data

Four commands: add, list, done <number>, quit. Each task is one dict — a title, a done flag, and a number for the done command to aim at:

{"id": 1, "title": "water the plants", "done": False}

The whole to-do list is just a list of those. No class needed at this size — and a bonus: a list of dicts is exactly what JSON stores natively, so saving is nearly free.

Saving and loading

Pathlib handles the file, json does the conversion:

import json
from pathlib import Path

STORE = Path("todo.json")

def load_tasks():
    if STORE.exists():
        return json.loads(STORE.read_text(encoding="utf-8"))
    return []

def save_tasks(tasks):
    STORE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")

First run there's no file, so load_tasks() returns an empty list — no exception handling needed when a plain check will do. And json.dumps(indent=2) writes the file so a human can read it, which has saved me twice.

The whole script

This is the complete program — save it as todo.py and run it with python todo.py:

import json
from pathlib import Path

STORE = Path("todo.json")

def load_tasks():
    if STORE.exists():
        return json.loads(STORE.read_text(encoding="utf-8"))
    return []

def save_tasks(tasks):
    STORE.write_text(json.dumps(tasks, indent=2), encoding="utf-8")

def next_id(tasks):
    if not tasks:
        return 1
    return max(t["id"] for t in tasks) + 1

def list_tasks(tasks):
    if not tasks:
        print("nothing yet - try 'add'")
        return
    for t in tasks:
        mark = "x" if t["done"] else " "
        print(f"{t['id']}. [{mark}] {t['title']}")

def main():
    tasks = load_tasks()
    print("commands: add, list, done <id>, quit")
    while True:
        line = input("> ").strip()
        if line == "quit":
            break
        if line == "add":
            title = input("what needs doing? ").strip()
            if title:
                tasks.append({"id": next_id(tasks), "title": title, "done": False})
                save_tasks(tasks)
        elif line == "list":
            list_tasks(tasks)
        elif line.startswith("done"):
            number = line[4:].strip()
            try:
                task_id = int(number)
            except ValueError:
                print("usage: done <number>")
                continue
            for t in tasks:
                if t["id"] == task_id:
                    t["done"] = True
                    save_tasks(tasks)
                    break
            else:
                print(f"no task number {task_id}")
        elif line:
            print("unknown command - try add, list, done <id>, quit")
    save_tasks(tasks)
    print("saved. bye")

main()

A few things worth pointing at. The while True loop is the whole app: read a line, dispatch, repeat. The try/except around int() is there because done fff is a thing people type. And the else hanging off the for loop runs when the loop finished without finding the id — for/else looks strange, but "loop through it, and run this if we never broke out" is exactly the meaning I want there.

A session with it

commands: add, list, done <id>, quit
> add
what needs doing? water the plants
> add
what needs doing? renew library card
> list
1. [ ] water the plants
2. [ ] renew library card
> done 1
> list
1. [x] water the plants
2. [ ] renew library card
> done 7
no task number 7
> quit
saved. bye

On disk, todo.json looks like what you'd hope:

[
  {
    "id": 1,
    "title": "water the plants",
    "done": true
  },
  {
    "id": 2,
    "title": "renew library card",
    "done": false
  }
]

Quit, run it again, and the list comes back loaded — the script's whole memory is that one file.

What I'd add next: a delete command, due dates, showing pending items first. But the current version already clears the bar I actually care about — it does its job, it survives being closed, and every line in it is one I understand. For a personal tool, that's the whole specification.