Python To-Do Application
Table of Contents + −
In the last lesson, Weather Application, you fetched live data from the internet. Now we come back to building a tool you would actually keep open every day: a to-do list. You add tasks, mark them done, and remove them, and the app remembers everything between runs. This project ties together the lists, dictionaries, and file handling you have practiced, into a clean little app. By the end you will have a working task manager you can really use.
🤔 What makes a good to-do app?
A to-do list sounds simple, but think about what it really needs to do. It is more than just a list of strings.
A useful to-do app should:
- Add a new task.
- Show all tasks, clearly marking which are done.
- Mark a task as done when you finish it.
- Remove a task you no longer need.
- Remember everything after you close it.
Notice each task has two parts: the text of the task, and whether it is done yet. So a plain string is not enough. We will store each task as a small dictionary with a title and a done flag. That extra flag is what lets us tick things off.
🧱 Step 1: The task data
Each task is a dictionary with the task text and a done value that is True or False. All the tasks live in a list. We also reuse our save and load helpers.
This is the data shape and the storage functions:
import json
# One task: {"title": "Buy milk", "done": False}# All tasks live in a list.
def save(tasks): with open("tasks.json", "w") as f: json.dump(tasks, f)
def load(): try: with open("tasks.json", "r") as f: return json.load(f) except FileNotFoundError: return []So each task carries its own done status. A new task starts with "done": False, and we flip it to True when it is finished. The save and load functions are the same pattern you have used before, with an empty list as the fresh start.
🛠️ Step 2: The task functions
Now the actions: add a task, show the tasks, mark one done, and delete one. We refer to each task by its number in the list, as the user sees it.
These functions handle the four actions:
def add_task(tasks, title): tasks.append({"title": title, "done": False})
def show_tasks(tasks): if not tasks: print("No tasks yet.") return for i, task in enumerate(tasks, start=1): mark = "x" if task["done"] else " " print(f"{i}. [{mark}] {task['title']}")
def mark_done(tasks, number): if 1 <= number <= len(tasks): tasks[number - 1]["done"] = True print("Marked done.") else: print("No task with that number.")
def delete_task(tasks, number): if 1 <= number <= len(tasks): removed = tasks.pop(number - 1) print(f"Removed: {removed['title']}") else: print("No task with that number.")Let’s read the tricky parts:
enumerate(tasks, start=1)gives both a number (starting at 1) and each task as we loop, so we can show “1.”, “2.” and so on. Humans count from 1, even though lists start at 0.- The
markis"x"if the task is done, or a space if not, giving a nice[x]or[ ]checkbox look. - For
mark_doneanddelete_task, the user gives a number starting at 1, but the list starts at 0. So we usenumber - 1to reach the right item. The check1 <= number <= len(tasks)makes sure the number is valid first.
Note
The gap between “the number the user sees” (starting at 1) and “the list position” (starting at 0) is a classic thing to handle. We show numbers from 1 with enumerate(..., start=1), then subtract 1 to get back to the list position. Keep that conversion in mind whenever you show a numbered list.
🖥️ Step 3: The complete program
Here is the full to-do app with a menu loop, saving after each change. Save it as todo.py and run it.
import json
def save(tasks): with open("tasks.json", "w") as f: json.dump(tasks, f)
def load(): try: with open("tasks.json", "r") as f: return json.load(f) except FileNotFoundError: return []
def add_task(tasks, title): tasks.append({"title": title, "done": False})
def show_tasks(tasks): if not tasks: print("No tasks yet.") return for i, task in enumerate(tasks, start=1): mark = "x" if task["done"] else " " print(f"{i}. [{mark}] {task['title']}")
def mark_done(tasks, number): if 1 <= number <= len(tasks): tasks[number - 1]["done"] = True print("Marked done.") else: print("No task with that number.")
def delete_task(tasks, number): if 1 <= number <= len(tasks): removed = tasks.pop(number - 1) print(f"Removed: {removed['title']}") else: print("No task with that number.")
def get_number(prompt): try: return int(input(prompt)) except ValueError: return -1
def main(): tasks = load() print("To-Do List")
while True: print("\n1) Add 2) Show 3) Mark done 4) Delete 5) Quit") choice = input("Choose an option: ")
if choice == "1": title = input("Task: ") add_task(tasks, title) save(tasks) print("Added!") elif choice == "2": show_tasks(tasks) elif choice == "3": show_tasks(tasks) mark_done(tasks, get_number("Number to mark done: ")) save(tasks) elif choice == "4": show_tasks(tasks) delete_task(tasks, get_number("Number to delete: ")) save(tasks) elif choice == "5": print("Goodbye!") break else: print("Pick 1 to 5.")
main()Here is a sample run:
Output
To-Do List
1) Add 2) Show 3) Mark done 4) Delete 5) QuitChoose an option: 1Task: Buy milkAdded!
1) Add 2) Show 3) Mark done 4) Delete 5) QuitChoose an option: 31. [ ] Buy milkNumber to mark done: 1Marked done.
1) Add 2) Show 3) Mark done 4) Delete 5) QuitChoose an option: 21. [x] Buy milk
1) Add 2) Show 3) Mark done 4) Delete 5) QuitChoose an option: 5Goodbye!There it is: a real to-do app. You can add tasks, tick them off with a clear [x], remove them, and it all stays saved between runs. This is a tool you could genuinely use, built entirely from skills you now have.
⚠️ Common Mistakes
A few traps in this project:
- Mixing up the user number and the list position. The user counts from 1, the list from 0. Forgetting
number - 1either crashes or changes the wrong task. - Not checking the number is valid. If the user types a number with no matching task, reaching that position raises an
IndexError. The range check prevents it. - Storing tasks as plain strings. Without a
doneflag, you cannot mark anything finished. A dictionary per task gives each one a status.
✅ Best Practices
Habits this project teaches:
- Store each item as a dictionary when it has more than one property, like a title and a done flag.
- Show numbered lists from 1 with
enumerate(..., start=1), and convert back withnumber - 1to reach the list. - Always validate the chosen number before using it, so a bad number never crashes the app.
- Save after every change, and load at the start, so the list survives between runs.
🧩 What You’ve Learned
✅ You built a to-do app that adds, shows, completes, and deletes tasks, and remembers them between runs.
✅ Each task is a dictionary with a title and a done flag, so you can tick items off.
✅ enumerate(tasks, start=1) shows a list numbered from 1, the way people expect.
✅ User numbers start at 1 but lists start at 0, so you convert with number - 1, after checking the number is valid.
✅ Saving to JSON after each change keeps the list safe between runs.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why is each task stored as a dictionary instead of a plain string?
Why: A task needs a title and a done status. A dictionary like {'title': 'Buy milk', 'done': False} holds both, which a plain string cannot.
- 2
What does enumerate(tasks, start=1) give you in the loop?
Why: enumerate gives both an index and the item. With start=1, the index counts from 1, which is how people read a numbered list.
- 3
The user picks task number 1, but the code uses tasks[number - 1]. Why subtract 1?
Why: People number tasks from 1, but Python lists start at index 0. Subtracting 1 converts the user's number to the correct list position.
- 4
What does the check 1 <= number <= len(tasks) prevent?
Why: If the number is out of range, reaching that list position would crash with an IndexError. The check confirms the number points to a real task first.
🚀 What’s Next?
You built a polished everyday tool. Next we focus on the data side: an API data fetcher that pulls information from an online service and saves it neatly to a file.