LearnAI ToolsCareerPractice BuildsPlayContact
Python ProgrammingBeginner~1.5 hours

Todo List CLI

Build a command-line task manager with persistent storage using file handling.

FunctionsFile I/OLists

Overview

A todo list is the project every language's beginners end up building, and for good reason: it is small enough to finish in an afternoon, yet it touches almost every core idea a script needs — functions that each do one job, a list of records held in memory while the program runs, and file I/O so those records are not lost the moment the program exits. Python makes the file-handling part almost too easy, which is exactly why this project is worth doing carefully: it is easy to write a todo app that "works," and a little harder to write one where a crash mid-run cannot silently erase your tasks.

By the end of this tutorial you will have a console task manager where each task is a plain Python `dict` living inside a list, and the whole list is serialized to a JSON file with the built-in `json` module every time it changes. That last detail — saving after every change rather than only on exit — is a deliberate design choice covered in Step 4, and it is the difference between a toy script and a tool you would actually trust with your own task list.

What You'll Build
  • A `load_tasks()`/`save_tasks()` pair that reads and writes tasks as JSON to a local file.
  • An `add_task()` function that assigns each task a unique, gap-safe id.
  • A `list_tasks()` function that prints every task with a `[ ]`/`[x]` completion marker.
  • A `complete_task()` function that marks a task done by id.
  • A `delete_task()` function that removes a task by id.
  • A menu-driven loop tying every operation together, saving to disk after each change.

Prerequisites

  • Functions — defining a function with `def`, parameters, and `return` values.
  • Lists — appending, iterating with `for`, and removing items with `pop()`.
  • Dictionaries — reading and writing values with a string key, e.g. `task["done"]`.
  • File handling basics — opening a file with `with open(...) as f:` so it always closes correctly.
  • The `json` module — `json.load()`/`json.dump()` for reading and writing structured data as text.

Project Structure

Everything lives in one file, `todo.py`. Each task is represented as a dictionary with three keys — `id`, `description`, and `done` — and every task in the program is stored together in a single Python list called `tasks`. No function in this project owns `tasks` the way a class would in an object-oriented language; instead, `tasks` is created once in `main()` and passed as the first argument into every function that needs to read or change it, which is the natural, function-first way Python scripts like this are usually structured.

Persistence goes through a single JSON file, `tasks.json`, which stores the entire task list as one JSON array of objects. JSON was chosen over a hand-rolled text format (like comma-separated lines) because Python's `json` module can serialize a list of dicts and get an identical list of dicts back with two function calls, with no manual parsing or risk of a comma inside a task description corrupting the file.

Step 1: Set Up Storage and Load/Save Functions

`load_tasks()` and `save_tasks()` are the only two functions in the whole program that touch the filesystem — every other function works purely with the in-memory `tasks` list, which keeps the rest of the code simple and easy to test. `load_tasks()` checks `os.path.exists()` first so that the very first run of the program, when no file exists yet, starts from an empty list instead of crashing.

import json # for saving/loading tasks as JSON so each task's dict structure survives a round trip to disk
import os # to check whether the tasks file exists before trying to read it
TASKS_FILE = "tasks.json" # single constant so the filename only ever needs to change in one place
def load_tasks():
"""Load the task list from disk, returning an empty list if no file exists yet."""
if not os.path.exists(TASKS_FILE): # first run ever: nothing has been saved yet, so start fresh
return []
with open(TASKS_FILE, "r") as f: # "with" guarantees the file is closed even if json.load() raises
return json.load(f) # parses the JSON array back into a Python list of dicts
def save_tasks(tasks):
"""Write the current task list to disk as JSON, overwriting whatever was there before."""
with open(TASKS_FILE, "w") as f:
json.dump(tasks, f, indent=2) # indent=2 keeps the file human-readable if you ever open it directly

Step 2: Add and List Tasks

`next_task_id()` is worth pausing on: a naive `len(tasks) + 1` would look correct until the first task is ever deleted, at which point two tasks could end up sharing an id. Computing the next id as one more than the highest id currently in the list avoids that trap entirely, no matter how many tasks have been added and removed before it runs.

def next_task_id(tasks):
"""Return the next unused task id: one more than the highest id currently in use."""
if not tasks:
return 1 # first task ever added starts the numbering at 1
return max(task["id"] for task in tasks) + 1 # generator expression avoids building a throwaway list just to call max()
def add_task(tasks, description):
"""Create a new task dict, append it to tasks, and return it so the caller can confirm what was added."""
task = {
"id": next_task_id(tasks), # id is computed, never chosen by the caller, so it can never collide
"description": description,
"done": False, # every new task starts out incomplete
}
tasks.append(task)
return task
def list_tasks(tasks):
"""Print every task with a checkbox-style marker showing whether it is done."""
if not tasks:
print("No tasks yet. Add one with option 1.")
return
for task in tasks:
marker = "[x]" if task["done"] else "[ ]" # filled box once done, empty box otherwise
print(f"{task['id']}. {marker} {task['description']}")
Example Usage

Click Run to see what this code prints.

Step 3: Complete and Delete Tasks

Both functions below share the same shape as `findAccount` did in the C++ bank project: scan the list looking for a matching id, act on it if found, and report back whether anything actually happened. Returning `True`/`False` rather than printing directly keeps these functions reusable — the menu loop in Step 4 decides what message to show, not `complete_task()` or `delete_task()` themselves.

def complete_task(tasks, task_id):
"""Mark the task with the given id as done. Returns True if a matching task was found."""
for task in tasks: # linear scan; more than fast enough for the size of a personal task list
if task["id"] == task_id:
task["done"] = True
return True
return False
def delete_task(tasks, task_id):
"""Remove the task with the given id entirely. Returns True if a task was removed."""
for index, task in enumerate(tasks): # enumerate() gives the index so pop() can remove the right element
if task["id"] == task_id:
tasks.pop(index)
return True
return False

Step 4: Build the Menu Loop

`main()` loads whatever was saved from the previous run, then loops on a numbered menu until the user exits. Every branch that changes `tasks` calls `save_tasks(tasks)` immediately afterward rather than waiting until the program ends — that way, if the terminal is closed unexpectedly or the process is killed, at most one action is ever lost instead of the whole session's work.

def print_menu():
print("\n===== TODO LIST CLI =====")
print("1. Add Task")
print("2. List Tasks")
print("3. Complete Task")
print("4. Delete Task")
print("5. Exit")
def main():
tasks = load_tasks() # restore whatever was saved from the previous run, or [] on the very first run
while True:
print_menu()
choice = input("Enter your choice: ").strip()
if choice == "1":
description = input("Task description: ").strip()
if not description: # reject a blank task instead of silently adding one
print("Task description cannot be empty.")
continue
task = add_task(tasks, description)
save_tasks(tasks) # persist immediately so this task survives a crash or Ctrl+C
print(f"Added task {task['id']}: {task['description']}")
elif choice == "2":
list_tasks(tasks)
elif choice == "3":
list_tasks(tasks)
try:
task_id = int(input("Task id to complete: "))
except ValueError: # input() always returns a string; guard against non-numeric text
print("Please enter a valid task id.")
continue
if complete_task(tasks, task_id):
save_tasks(tasks)
print("Task marked complete.")
else:
print("No task with that id.")
elif choice == "4":
list_tasks(tasks)
try:
task_id = int(input("Task id to delete: "))
except ValueError:
print("Please enter a valid task id.")
continue
if delete_task(tasks, task_id):
save_tasks(tasks)
print("Task deleted.")
else:
print("No task with that id.")
elif choice == "5":
print("Goodbye!")
break # exits the while loop; the program ends right after
else:
print("Invalid choice, try again.")
if __name__ == "__main__": # only runs main() when this file is executed directly, not when imported
main()

Complete Code

Here is the full program with every function assembled in the correct order, ready to run with `python todo.py`.

import json # for saving/loading tasks as JSON so each task's dict structure survives a round trip to disk
import os # to check whether the tasks file exists before trying to read it
TASKS_FILE = "tasks.json" # single constant so the filename only ever needs to change in one place
def load_tasks():
"""Load the task list from disk, returning an empty list if no file exists yet."""
if not os.path.exists(TASKS_FILE):
return []
with open(TASKS_FILE, "r") as f:
return json.load(f)
def save_tasks(tasks):
"""Write the current task list to disk as JSON, overwriting whatever was there before."""
with open(TASKS_FILE, "w") as f:
json.dump(tasks, f, indent=2)
def next_task_id(tasks):
"""Return the next unused task id: one more than the highest id currently in use."""
if not tasks:
return 1
return max(task["id"] for task in tasks) + 1
def add_task(tasks, description):
"""Create a new task dict, append it to tasks, and return it so the caller can confirm what was added."""
task = {
"id": next_task_id(tasks),
"description": description,
"done": False,
}
tasks.append(task)
return task
def list_tasks(tasks):
"""Print every task with a checkbox-style marker showing whether it is done."""
if not tasks:
print("No tasks yet. Add one with option 1.")
return
for task in tasks:
marker = "[x]" if task["done"] else "[ ]"
print(f"{task['id']}. {marker} {task['description']}")
def complete_task(tasks, task_id):
"""Mark the task with the given id as done. Returns True if a matching task was found."""
for task in tasks:
if task["id"] == task_id:
task["done"] = True
return True
return False
def delete_task(tasks, task_id):
"""Remove the task with the given id entirely. Returns True if a task was removed."""
for index, task in enumerate(tasks):
if task["id"] == task_id:
tasks.pop(index)
return True
return False
def print_menu():
print("\n===== TODO LIST CLI =====")
print("1. Add Task")
print("2. List Tasks")
print("3. Complete Task")
print("4. Delete Task")
print("5. Exit")
def main():
tasks = load_tasks()
while True:
print_menu()
choice = input("Enter your choice: ").strip()
if choice == "1":
description = input("Task description: ").strip()
if not description:
print("Task description cannot be empty.")
continue
task = add_task(tasks, description)
save_tasks(tasks)
print(f"Added task {task['id']}: {task['description']}")
elif choice == "2":
list_tasks(tasks)
elif choice == "3":
list_tasks(tasks)
try:
task_id = int(input("Task id to complete: "))
except ValueError:
print("Please enter a valid task id.")
continue
if complete_task(tasks, task_id):
save_tasks(tasks)
print("Task marked complete.")
else:
print("No task with that id.")
elif choice == "4":
list_tasks(tasks)
try:
task_id = int(input("Task id to delete: "))
except ValueError:
print("Please enter a valid task id.")
continue
if delete_task(tasks, task_id):
save_tasks(tasks)
print("Task deleted.")
else:
print("No task with that id.")
elif choice == "5":
print("Goodbye!")
break
else:
print("Invalid choice, try again.")
if __name__ == "__main__":
main()

Sample Run

Sample Run

Click Run to see what this code prints.

Extend This Project

  • Add a `priority` field (low/medium/high) and sort `list_tasks()` output by it.
  • Add due dates using the `datetime` module and highlight overdue tasks in the listing.
  • Support tagging tasks with categories and a `filter_by_category()` function to list just one category.
  • Add an `edit_task()` function that lets the user change a task's description without deleting and re-adding it.
  • Replace the numbered menu with `argparse` so tasks can be managed from shell commands like `python todo.py add "Buy milk"`.

Summary

You built a working todo list manager that keeps every operation as a small, single-purpose function operating on a plain list of dicts, with `load_tasks()` and `save_tasks()` as the only two places file I/O happens. The gap-safe id generation in `next_task_id()` and the save-after-every-change habit in the menu loop are both small decisions that matter far more than they look — they are the difference between a script that merely runs and one that is actually safe to rely on.