Build a To-Do List App
Learn to build a simple to-do list application in this hands-on Python tutorial – step-by-step guide with code examples and troubleshooting tips.
Focus: build a simple to-do list application
Building your own to-do list application is a classic rite of passage for any Python developer. It's the perfect project to move beyond isolated syntax exercises and start combining Python's core features — input handling, data storage, conditional logic, and loops — into a functional tool you can actually use. By the end of this lesson, you won't just understand the pieces; you'll have a working, command-line to-do list that you can run, modify, and even extend into a web app someday.
The problem this lesson solves
Most Python tutorials teach you individual concepts in a vacuum: for loops here, if statements there, file I/O somewhere else. The problem is, you rarely see how they fit together to solve a real, everyday problem. A to-do list forces you to make design decisions: how do you store tasks? How does a user add, view, and complete a task? How do you prevent the program from crashing on unexpected input?
Without a guided project, beginners often get stuck on architecture ("Should I use a list or a dictionary?") or lose motivation halfway through. This lesson provides a structured, repeatable blueprint so you can build a simple to-do list application with confidence and then use it as a foundation for more complex projects.
Core concept / mental model
Think of your to-do list as a digital notebook with a strict set of rules. The notebook has a cover (the program's menu), pages for each task, and a way to flip through them. In Python, we model this with:
- A list of dictionaries — each task is a dictionary with keys like
"id","task", and"done". - A while loop that keeps the program running until the user says "quit."
- Functions that encapsulate each action (add, view, mark done, delete).
- Conditional statements to handle user choices.
Pro tip: The key mental shift is from reacting to user input (a script that runs once) to managing a state (a program that maintains data across multiple interactions). This is your first step toward building stateful applications.
How it works step by step
The main menu loop
The program starts by showing a menu of actions. This is the "brain" — a while True loop that keeps asking for commands until the user enters "5" (quit).
Representing tasks
Each task is a dictionary like this:
todo_list = []
task = {"id": 1, "task": "Learn Python lists", "done": False}
We store these dictionaries in a list. The id helps us identify tasks uniquely (since task names could be duplicated), and done is a Boolean flag.
The four core actions
- Add a task — Append a new dictionary to the list. Auto-increment the
idbased on the current length or a counter. - View tasks — Loop through the list and print each task, optionally filtering by
donestatus. - Mark as done — Find a task by
id, set"done": True. - Delete a task — Find a task by
idand remove it from the list withpop()or list comprehension.
Input validation
Always wrap user input in a try/except block when expecting a number (like task ID). Invalid input should not crash the program — it should display a friendly error and show the menu again.
try:
choice = int(input("> "))
except ValueError:
print("Please enter a number.")
continue
Hands-on walkthrough
Let's build the application! We'll write a single Python file, todo.py, and run it from the terminal.
Step 1: Define the data structure and global variables
# Global list to store tasks
todo_list = []
next_id = 1 # Auto-incrementing ID
Step 2: Define helper functions
def show_menu():
print("\n--- TO-DO LIST ---")
print("1. Add a task")
print("2. View all tasks")
print("3. Mark a task as done")
print("4. Delete a task")
print("5. Quit")
def add_task():
global next_id
task_name = input("Enter the task: ").strip()
if not task_name:
print("Task cannot be empty.")
return
new_task = {"id": next_id, "task": task_name, "done": False}
todo_list.append(new_task)
next_id += 1
print(f"Added task: {task_name}")
def view_tasks():
if not todo_list:
print("No tasks yet.")
return
print("\nYour tasks:")
for task in todo_list:
status = "[X]" if task["done"] else "[ ]"
print(f"{task['id']}. {status} {task['task']}")
def mark_done():
view_tasks()
try:
task_id = int(input("Enter task ID to mark as done: "))
except ValueError:
print("Invalid ID.")
return
for task in todo_list:
if task["id"] == task_id:
task["done"] = True
print(f"Task {task_id} marked as done.")
return
print(f"Task with ID {task_id} not found.")
def delete_task():
view_tasks()
try:
task_id = int(input("Enter task ID to delete: "))
except ValueError:
print("Invalid ID.")
return
for i, task in enumerate(todo_list):
if task["id"] == task_id:
del todo_list[i]
print(f"Deleted task {task_id}.")
return
print(f"Task with ID {task_id} not found.")
Step 3: The main loop
def main():
while True:
show_menu()
try:
choice = int(input("> "))
except ValueError:
print("Please enter a number 1–5.")
continue
if choice == 1:
add_task()
elif choice == 2:
view_tasks()
elif choice == 3:
mark_done()
elif choice == 4:
delete_task()
elif choice == 5:
print("Goodbye!")
break
else:
print("Invalid choice. Enter 1–5.")
if __name__ == "__main__":
main()
Step 4: Run it
Save the file and run from your terminal:
python todo.py
You'll see:
--- TO-DO LIST ---
1. Add a task
2. View all tasks
3. Mark a task as done
4. Delete a task
5. Quit
> 1
Enter the task: Buy groceries
Added task: Buy groceries
> 2
Your tasks:
1. [ ] Buy groceries
> 3
Your tasks:
1. [ ] Buy groceries
Enter task ID to mark as done: 1
Task 1 marked as done.
> 2
Your tasks:
1. [X] Buy groceries
> 5
Goodbye!
Compare options / when to choose what
| Approach | Pros | Cons | Best for |
|---|---|---|---|
| Global list + functions | Simple, no extra libraries | No persistence (tasks lost on restart) | Quick scripts, learning |
| File-based persistence (JSON) | Survives restarts, readable | Requires file I/O logic | Single-user local use |
| Database (SQLite) | Scalable, concurrent access | More setup, learning curve | Multi-user or web apps |
Object-Oriented (class TodoList) |
Reusable, testable | Overkill for tiny projects | Larger projects |
For this lesson, the global list is perfect. But in the next lesson, we'll upgrade to JSON file persistence so your tasks survive a program restart.
Troubleshooting & edge cases
User enters non-numeric choice
--- TO-DO LIST ---
1. Add a task
> abc
Please enter a number 1–5.
Our try/except handles it gracefully.
Task ID not found
If the user enters an ID that doesn't exist (e.g., after deleting a task), the loop returns a clear error without crashing.
Empty task name
The add_task() function checks for empty strings with .strip(). This prevents adding blank entries.
Multiple tasks with same name
Because we use a unique id for each task, duplicate names are fine. Operations like mark-done and delete rely on id, not the task text.
Forgetting global keyword
def add_task():
next_id += 1 # UnboundLocalError!
Always declare global next_id inside the function if you intend to modify the variable. This is a common newbie trap.
Pro tip: Use
globalsparingly. In larger applications, you'd wrap everything in a class to avoid globals entirely.
What you learned & what's next
You've just built a complete, interactive to-do list application from scratch! You now understand:
- How to combine loops, conditionals, lists, dictionaries, and functions into a coherent program.
- How to handle user input and validate it without crashing.
- How to design a menu-driven interface.
- How to model a "state" (the task list) and mutate it through user actions.
This is a foundational skill for any backend or full-stack Python developer.
What's next: In the next lesson, we'll enhance this to-do list by adding data persistence using JSON files — so your tasks live on even when you close the program. You'll learn to read and write structured data, handle file errors, and think about data integrity.
For now, run your program, add a few tasks, mark them done, delete some — break it on purpose! The best way to learn is to tweak the code and see what happens. Try adding a filter view that only shows incomplete tasks, or a priority field. Happy coding!
Practice recap
Practice challenge: Add a new menu option 6. Show incomplete tasks that prints only tasks where 'done' is False. You'll need to write a new function view_incomplete() and add an elif branch in main(). Test it with a mix of complete and incomplete tasks. This exercise reinforces conditionals inside loops and user-defined functions.
Common mistakes
- Forgetting to declare
global next_idinside your functions, which raisesUnboundLocalError. - Using a
breakstatement inside aforloop that searches for a task ID, but forgetting toreturnafter finding it — the loop might print 'not found' after processing. - Not stripping whitespace from user input, leading to tasks with leading/trailing spaces that look identical but are technically different.
- Assuming the user will always enter a valid integer for menu choices — wrapping
int(input())in atry/exceptis essential.
Variations
- Instead of a flat list of dictionaries, you could define a
Taskclass usingdataclassesfor cleaner, more readable code with type hints. - Use an infinite loop with a
match-casestatement (Python 3.10+) to handle menu choices, which can be more readable than a chain ofelif. - Implement a 'pin' feature that lets users sort or filter tasks by priority (e.g., high, medium, low) by adding a
"priority"key to each task dictionary.
Real-world use cases
- A simple CLI tool for tracking personal daily tasks, like a grocery list or reading list, without needing a network connection.
- A lightweight bug-tracker prototype where each 'task' represents an issue with a 'done' flag for resolved status — useful for small team sprints.
- A scheduling assistant that reads tasks from a file and prints a daily agenda, integrated into a shell startup script (e.g.,
.bashrc).
Key takeaways
- A to-do list application combines lists of dictionaries, a main while loop, input validation, and functions into a cohesive program.
- Using a unique
idper task prevents ambiguity when deleting or marking tasks, even if names are duplicates. - Always wrap
int(input())in atry/except ValueErrorto handle non-numeric input gracefully. - The
globalkeyword is required when modifying a global variable (likenext_id) inside a function — but consider using a class for larger projects. - A modular design with one function per action makes the code easy to extend, debug, and test.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.