Build a Checkbook Register with Flask

Build a local web app with Flask, SQLite, and HTML/CSS/JS for tracking transactions.

Introduction

30 Second Summary

Every time you swipe your card or write a check, your bank balance changes. Keeping track of where your money goes is one of the most practical problems software can solve.

In this project, you will build a local checkbook register web application using Python and Flask. You will open your browser, see a clean ledger of transactions, and be able to add, edit, and delete entries with a running balance that updates automatically.

What You'll Build

A Flask web application connected to a SQLite database, serving HTML templates with CSS styling and JavaScript interactivity.

By the end of this project, you'll have:

  • A working Flask application with routes for viewing, adding, editing, and deleting transactions.
  • A SQLite database storing your transaction history with a running balance calculated automatically.
  • A styled frontend using Jinja2 templates, CSS ledger styling, and JavaScript that highlights balances by color.
  • Secret Mission: Add category tagging and a spending dashboard showing totals by category.

Are there any prerequisites?

None. This guide walks you through installing everything you need, including Python and Flask. Basic comfort with typing commands in a terminal is helpful but not required.

Set Up Your Project and Install Flask

Every web application needs a home. Before you write any code, you need a dedicated folder for your project and an isolated environment to install packages without affecting the rest of your computer.

In this step, you will create a project folder, set up a Python virtual environment, install Flask, and create all the empty files your app will need.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install Flask and verify it works.
  • Create the project file structure.
Create your project folder and virtual environment

A virtual environment is a self-contained copy of Python that keeps your project's packages separate from your system Python. This prevents version conflicts between projects.

  • Open a terminal in VS Code by pressing Ctrl+` (the backtick key, below Escape).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir checkbook
cd checkbook

Now you need to create and activate a virtual environment. The commands differ between macOS and Windows.

macOS

  • Create and activate your virtual environment by running these commands:
python3 -m venv .venv
source .venv/bin/activate

Windows

  • Create and activate your virtual environment by running these commands:
python -m venv .venv
.venv\Scripts\activate

What is a virtual environment?

A virtual environment creates an isolated Python installation inside your project folder. Packages you install here (like Flask) stay contained to this project.

The .venv folder holds the isolated Python interpreter and any packages you install with pip.

  • Confirm your virtual environment is active by checking your terminal prompt.
  • You should see (.venv) at the beginning of your terminal line.

Don't see (.venv) in your prompt?

  • Make sure you ran the activation command (not just the creation command). The source or .venv\Scripts\activate line is what switches your terminal into the virtual environment.
  • On Windows, if you see a permissions error, open PowerShell as Administrator and run Set-ExecutionPolicy RemoteSigned, then try activating again.

Need more help? Help me activate my Python virtual environment.

Install Flask and verify

Flask is a lightweight web framework for Python. It handles routing URLs to your code, rendering HTML templates, and serving your app in the browser.

  • Install Flask by running this command:
pip install Flask
  • Verify the installation by running:
python -c "import flask; print(flask.__version__)"
  • You should see a version number like 3.1.3 printed in your terminal.

Seeing an error instead of a version number?

  • Make sure your virtual environment is still active (you see (.venv) in your prompt). If not, re-run the activation command from the previous substep.
  • If pip returned an error during install, check your internet connection and try running the install command again.

Still stuck? Help me install Flask in my virtual environment.

Create the project file structure

Your checkbook app will have a Python backend, HTML templates, a CSS stylesheet, and a JavaScript file. You will create all these empty files now so the structure is ready for the next steps.

macOS

  • Create the folders and files by running these commands:
mkdir templates static
touch app.py schema.sql
touch templates/base.html templates/index.html templates/add.html templates/edit.html
touch static/style.css static/app.js

Windows

  • Create the folders and files by running these commands:
mkdir templates, static
New-Item -ItemType "File" -Path "app.py", "schema.sql", "templates\base.html", "templates\index.html", "templates\add.html", "templates\edit.html", "static\style.css", "static\app.js"

What are these files for?

  • app.py is your main Flask application with all the routes and logic.
  • schema.sql defines your database table structure.
  • templates/ holds HTML files that Flask renders. Flask looks for templates in this specific folder name.
  • static/ holds CSS and JavaScript files. Flask serves these directly to the browser.
  • Confirm the files exist by running:
ls
  • You should see app.py, schema.sql, static, and templates listed in the output.

Missing files or folders?

  • Make sure you are inside the checkbook folder when you run the file creation commands. Run pwd (macOS) or Get-Location (Windows) to check your current directory.
  • If only some files are missing, re-run the specific touch or New-Item command for the missing ones.

Need help? Help me create my project file structure.

  • Open the checkbook folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the checkbook folder on your Desktop, then click Open.

You should see all your files and folders in the Explorer sidebar on the left.

Your workspace is ready. Next up, you will write the database schema and connect Flask to SQLite.

Create the Database Schema and Connection

Your project files are ready. Now you need a database to store all the transactions your checkbook register will track.

In this step, you will define a database table structure and write Python functions that connect your Flask app to a SQLite database. By the end, you will have an initialized database file ready to hold transaction data.

In this step, get ready to:
  • Define the database schema for your transactions table.
  • Write helper functions that open and close database connections.
  • Initialize the database with a custom Flask CLI command.
Write the SQLite schema

A schema defines the structure of your database tables. Think of it as a blueprint that tells SQLite what columns to create and what type of data each column holds.

  • Add the following SQL to schema.sql to define the transactions table:
DROP TABLE IF EXISTS transactions;

CREATE TABLE transactions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    date TEXT NOT NULL,
    check_number TEXT,
    description TEXT NOT NULL,
    amount REAL NOT NULL,
    transaction_type TEXT NOT NULL
);

What does this code do?

  • DROP TABLE IF EXISTS transactions removes any old version of the table so you start fresh every time you initialize.
  • CREATE TABLE transactions defines a table with six columns: a unique id, a date, an optional check_number, a description, a numeric amount, and a transaction_type that will be either 'deposit' or 'withdrawal'.
  • NOT NULL means those fields are required. check_number is optional because not every transaction involves a check.
  • Save schema.sql.

You won't see a change yet. The next chunk uses this schema to create the actual database file.

Seeing a typo warning in VS Code?

VS Code may underline SQL keywords if it does not have a SQL extension installed. This is cosmetic and will not affect your app.

Help me troubleshoot SQL highlighting issues in VS Code.

Write the database helper functions in app.py

Flask needs a way to open a database connection when handling a request and close it when finished. You will write helper functions that manage this lifecycle.

  • Add the following imports and app configuration to the top of app.py:
import sqlite3
import os

import click
from flask import Flask, render_template, request, redirect, url_for, flash, g

app = Flask(__name__)
app.config['DATABASE'] = os.path.join(app.instance_path, 'checkbook.db')
app.config['SECRET_KEY'] = 'dev'

os.makedirs(app.instance_path, exist_ok=True)

What does this code do?

  • app = Flask(__name__) creates the Flask application instance.
  • app.config['DATABASE'] points to checkbook.db inside Flask's instance folder. This keeps your database file separate from your application code.
  • SECRET_KEY is required for Flask's flash messaging system. The value 'dev' is fine for local development.
  • os.makedirs ensures the instance folder exists before you try to create a database file inside it.
  • Save app.py.

You won't see a change yet. The next chunk adds the functions that use this configuration.

  • Below the config block in app.py, add the get_db() function:

def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(
            app.config['DATABASE'],
            detect_types=sqlite3.PARSE_DECLTYPES
        )
        g.db.row_factory = sqlite3.Row
    return g.db

What does this code do?

Flask's g object stores data for the duration of a single request. This function checks if a database connection already exists on g. If not, it opens a new connection.

Setting row_factory = sqlite3.Row lets you access columns by name (like row['date']) instead of by index number.

  • Save app.py.

You won't see a change yet. The next chunk adds the remaining lifecycle functions.

  • Below get_db(), add the close_db() and init_db() functions:

def close_db(e=None):
    db = g.pop('db', None)
    if db is not None:
        db.close()


def init_db():
    db = get_db()
    with app.open_resource('schema.sql') as f:
        db.executescript(f.read().decode('utf8'))

What do these functions do?

  • close_db() removes the connection from g and closes it. Flask calls this automatically after each request finishes.
  • init_db() opens schema.sql and runs all the SQL inside it. This creates the transactions table in your database.
  • Save app.py.

You won't see a change yet. The next chunk adds the CLI command that triggers initialization.

Seeing import errors in VS Code?

If VS Code underlines flask or click with a warning, make sure your Python interpreter is set to the .venv environment. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and choose the one with .venv in its path.

Help me fix Python interpreter issues in VS Code.

Initialize the database

You have the schema and helper functions, but the database file does not exist yet. You need a command that runs init_db() to create the actual checkbook.db file.

  • Add the following CLI command and registration lines at the bottom of app.py, below the init_db() function:

@click.command('init-db')
def init_db_command():
    """Clear the existing data and create new tables."""
    init_db()
    click.echo('Initialized the database.')


app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)

What does this code do?

  • @click.command('init-db') creates a terminal command called init-db that you can run through Flask's CLI.
  • app.teardown_appcontext(close_db) tells Flask to call close_db() automatically after every request.
  • app.cli.add_command(init_db_command) registers your command so Flask knows about it.
  • Save app.py.
  • Initialize your database by running this command in the terminal:
flask --app app init-db

You should see the message Initialized the database. printed in your terminal.

  • Confirm the database file was created by running:
ls instance/

You should see checkbook.db listed in the output.

Command not found or no output?

  • Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt.
  • Check that you are in the checkbook folder where app.py lives. The flask command looks for app.py in the current directory.
  • If you see a syntax error, double-check that app.py has correct indentation. Python is sensitive to spacing.

Help me troubleshoot the init-db command.

✔️ Awesome, I've got everything!

Great. Double check you have saved both schema.sql and app.py, then move on to the next step.

ⓧ I'd like to double check the full code

Here is the full schema.sql:

DROP TABLE IF EXISTS transactions;

CREATE TABLE transactions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    date TEXT NOT NULL,
    check_number TEXT,
    description TEXT NOT NULL,
    amount REAL NOT NULL,
    transaction_type TEXT NOT NULL
);

Here is the full app.py:

import sqlite3
import os

import click
from flask import Flask, render_template, request, redirect, url_for, flash, g

app = Flask(__name__)
app.config['DATABASE'] = os.path.join(app.instance_path, 'checkbook.db')
app.config['SECRET_KEY'] = 'dev'

os.makedirs(app.instance_path, exist_ok=True)


def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(
            app.config['DATABASE'],
            detect_types=sqlite3.PARSE_DECLTYPES
        )
        g.db.row_factory = sqlite3.Row
    return g.db


def close_db(e=None):
    db = g.pop('db', None)
    if db is not None:
        db.close()


def init_db():
    db = get_db()
    with app.open_resource('schema.sql') as f:
        db.executescript(f.read().decode('utf8'))


@click.command('init-db')
def init_db_command():
    """Clear the existing data and create new tables."""
    init_db()
    click.echo('Initialized the database.')


app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)

Your database is ready and waiting for transactions. Next up, you will build the Flask routes that let you view and add transactions through your browser.

Build the Routes for Viewing and Adding Transactions

Your database is set up and ready to store transactions. Now you need to give your app a way to actually show those transactions and add new ones.

In Flask, you define routes to tell the app what to do when someone visits a specific URL. You will create two routes: one that displays all transactions in your register, and one that handles adding a new transaction.

In this step, get ready to:
  • Create an index route that queries transactions and calculates a running balance.
  • Create an add route that handles form submission and database insertion.
  • Start the Flask dev server and verify the app loads in your browser.
Create the index route to display all transactions

When someone visits the homepage of your app, they should see all their transactions listed in order with a running balance. The index route handles this by querying the database and calculating how much money is left after each transaction.

  • In app.py, scroll to the bottom of the file (after the app.cli.add_command(init_db_command) line).
  • Add the index route by pasting this code at the end of the file:
@app.route('/')
def index():
    db = get_db()
    # Query all transactions, sorted by date then by ID
    transactions = db.execute(
        'SELECT id, date, check_number, description, amount, transaction_type'
        ' FROM transactions ORDER BY date ASC, id ASC'
    ).fetchall()

    # Calculate a running balance for each transaction
    running_balance = 0.0
    entries = []
    for txn in transactions:
        if txn['transaction_type'] == 'deposit':
            running_balance += txn['amount']
        else:
            running_balance -= txn['amount']
        entries.append({
            'id': txn['id'],
            'date': txn['date'],
            'check_number': txn['check_number'],
            'description': txn['description'],
            'amount': txn['amount'],
            'transaction_type': txn['transaction_type'],
            'balance': running_balance
        })

    return render_template('index.html', entries=entries)

What does this code do?

The @app.route('/') decorator tells Flask to run this function when someone visits the homepage.

The SQL query pulls every transaction from the database, ordered by date first and then by ID (so transactions on the same day appear in the order they were entered).

The for loop builds a running balance. Deposits add to the total, withdrawals subtract. Each entry gets its own snapshot of the balance at that point in time.

Why calculate balance in Python instead of the database?

A running balance depends on every row that came before it. Calculating it in Python with a simple loop is easier to understand and debug than writing a complex SQL window function. It also means the balance updates correctly whenever you edit or delete a transaction.

  • Save app.py.

You won't see a change yet because the template is still empty. The next chunk adds the route for creating new transactions.

Create the add transaction route

This route needs to handle two situations. When a user navigates to the add page (a GET request), it shows a blank form. When the user submits that form (a POST request), it validates the data and inserts a new row into the database.

  • In app.py, add the following code below the index() function you just wrote:
@app.route('/add', methods=['GET', 'POST'])
def add():
    if request.method == 'POST':
        # Extract form fields
        date = request.form['date']
        check_number = request.form['check_number']
        description = request.form['description']
        amount = request.form['amount']
        transaction_type = request.form['transaction_type']
        error = None

        # Validate required fields
        if not date:
            error = 'Date is required.'
        elif not description:
            error = 'Description is required.'
        elif not amount:
            error = 'Amount is required.'

        if error is None:
            # Insert the new transaction into the database
            db = get_db()
            db.execute(
                'INSERT INTO transactions (date, check_number, description, amount, transaction_type)'
                ' VALUES (?, ?, ?, ?, ?)',
                (date, check_number, description, float(amount), transaction_type)
            )
            db.commit()
            flash('Transaction added successfully.')
            return redirect(url_for('index'))

        flash(error)

    return render_template('add.html')

What does this code do?

The methods=['GET', 'POST'] parameter tells Flask this route responds to both types of HTTP requests. A GET request shows the form. A POST request processes the submitted data.

The validation section checks that the user filled in the required fields (date, description, amount). If anything is missing, flash() stores an error message to display on the page.

The ? placeholders in the SQL query are parameterized queries. They prevent SQL injection by safely escaping user input before it reaches the database.

  • Save app.py.

Seeing a red underline or import error?

Make sure request, redirect, url_for, and flash are all listed in the from flask import ... line at the top of your file. These were added in the previous step.

help me debug a Flask import error in app.py

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py file at this point:

import sqlite3
import os

import click
from flask import Flask, render_template, request, redirect, url_for, flash, g

app = Flask(__name__)
app.config['DATABASE'] = os.path.join(app.instance_path, 'checkbook.db')
app.config['SECRET_KEY'] = 'dev'

os.makedirs(app.instance_path, exist_ok=True)


def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(
            app.config['DATABASE'],
            detect_types=sqlite3.PARSE_DECLTYPES
        )
        g.db.row_factory = sqlite3.Row
    return g.db


def close_db(e=None):
    db = g.pop('db', None)
    if db is not None:
        db.close()


def init_db():
    db = get_db()
    with app.open_resource('schema.sql') as f:
        db.executescript(f.read().decode('utf8'))


@click.command('init-db')
def init_db_command():
    """Clear the existing data and create new tables."""
    init_db()
    click.echo('Initialized the database.')


app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)


@app.route('/')
def index():
    db = get_db()
    transactions = db.execute(
        'SELECT id, date, check_number, description, amount, transaction_type'
        ' FROM transactions ORDER BY date ASC, id ASC'
    ).fetchall()

    running_balance = 0.0
    entries = []
    for txn in transactions:
        if txn['transaction_type'] == 'deposit':
            running_balance += txn['amount']
        else:
            running_balance -= txn['amount']
        entries.append({
            'id': txn['id'],
            'date': txn['date'],
            'check_number': txn['check_number'],
            'description': txn['description'],
            'amount': txn['amount'],
            'transaction_type': txn['transaction_type'],
            'balance': running_balance
        })

    return render_template('index.html', entries=entries)


@app.route('/add', methods=['GET', 'POST'])
def add():
    if request.method == 'POST':
        date = request.form['date']
        check_number = request.form['check_number']
        description = request.form['description']
        amount = request.form['amount']
        transaction_type = request.form['transaction_type']
        error = None

        if not date:
            error = 'Date is required.'
        elif not description:
            error = 'Description is required.'
        elif not amount:
            error = 'Amount is required.'

        if error is None:
            db = get_db()
            db.execute(
                'INSERT INTO transactions (date, check_number, description, amount, transaction_type)'
                ' VALUES (?, ?, ?, ?, ?)',
                (date, check_number, description, float(amount), transaction_type)
            )
            db.commit()
            flash('Transaction added successfully.')
            return redirect(url_for('index'))

        flash(error)

    return render_template('add.html')
Start the Flask dev server and test

Both routes are in place. Time to start the Flask development server and confirm everything loads without errors.

  • In your VS Code terminal (with the virtual environment active), start the dev server by running:
flask --app app run --debug

What does this command do?

The --app app flag tells Flask which file contains your application (app.py). The --debug flag enables auto-reloading whenever you save a file, so you do not need to restart the server after every change.

  • Open your web browser and navigate to http://127.0.0.1:5000.
  • You should see a blank page (or a template error) because the HTML templates are still empty. That is expected.

Check your terminal output

If the server started correctly, your terminal shows a line with Running on http://127.0.0.1:5000. If you see a Python traceback instead, read the error message carefully for typos or missing imports.

Server won't start?

Make sure your virtual environment is active (you should see (.venv) in your terminal prompt). If not, run .venv\Scripts\activate again.

Check that you are in the checkbook folder (the one containing app.py). Flask needs to find app.py in the current directory.

help me fix my Flask server not starting

Your routes are wired up and the server is running. Next up, you will build the HTML templates and CSS so the page actually shows your transactions in a clean ledger-style table.

Build the HTML Templates and CSS

Your routes are ready and the database is waiting for data. The missing piece is the visual layer that lets you interact with the app in your browser.

In this step, you will create Jinja2 templates that define the page structure and a CSS stylesheet that gives the register a clean ledger appearance.

In this step, get ready to:
  • Create a base template with shared layout, navigation, and flash messages.
  • Build the index and add transaction templates.
  • Style the register with CSS for a professional ledger look.
Create the base template with layout structure

Flask uses Jinja2 template inheritance so you can define a shared layout once and reuse it across every page. The base template holds the HTML boilerplate, stylesheet link, navigation bar, flash message area, and a content block that child templates fill in.

  • In VS Code, open templates/base.html and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Checkbook Register</title>
    <!-- Link to the CSS stylesheet in static/ -->
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
    <header>
        <h1>Checkbook Register</h1>
        <!-- Navigation links to the main register and add form -->
        <nav>
            <a href="{{ url_for('index') }}">Register</a>
            <a href="{{ url_for('add') }}">Add Transaction</a>
        </nav>
    </header>
    <main>
        <!-- Display flash messages from Flask -->
        {% with messages = get_flashed_messages() %}
            {% if messages %}
                <div class="flash-messages">
                    {% for message in messages %}
                        <p class="flash">{{ message }}</p>
                    {% endfor %}
                </div>
            {% endif %}
        {% endwith %}
        <!-- Child templates inject their content here -->
        {% block content %}{% endblock %}
    </main>
    <!-- JavaScript file loaded at the end of body -->
    <script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

What does this template do?

The url_for('static', filename='style.css') call generates the correct path to your CSS file inside the static/ folder. Flask handles the URL automatically.

The {% block content %}{% endblock %} tag is a placeholder. Every child template that extends this base fills in its own content at that spot.

The get_flashed_messages() function retrieves any one-time messages set by your routes (like success or error alerts) and displays them at the top of the page.

  • Save templates/base.html.
  • You won't see a change in the browser yet because index.html doesn't reference this base template. The next chunk connects them.
Create the index and add templates

The index template displays all transactions in a table with columns for date, check number, description, amounts, running balance, and action links. It extends the base template to inherit the shared layout.

  • Open templates/index.html and paste the following code:
{% extends 'base.html' %}

{% block content %}
<!-- Transaction register table -->
<table>
    <thead>
        <tr>
            <th>Date</th>
            <th>Check #</th>
            <th>Description</th>
            <th>Withdrawal</th>
            <th>Deposit</th>
            <th>Balance</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody>
        <!-- Loop through each transaction entry passed from the route -->
        {% for entry in entries %}
        <tr>
            <td>{{ entry.date }}</td>
            <td>{{ entry.check_number }}</td>
            <td>{{ entry.description }}</td>
            <!-- Show amount in Withdrawal column only for withdrawals -->
            <td>{% if entry.transaction_type == 'withdrawal' %}${{ "%.2f"|format(entry.amount) }}{% endif %}</td>
            <!-- Show amount in Deposit column only for deposits -->
            <td>{% if entry.transaction_type == 'deposit' %}${{ "%.2f"|format(entry.amount) }}{% endif %}</td>
            <!-- Balance cell with data attribute for JavaScript highlighting -->
            <td class="balance" data-balance="{{ entry.balance }}">${{ "%.2f"|format(entry.balance) }}</td>
            <td class="actions">
                <a href="{{ url_for('edit', id=entry.id) }}">Edit</a>
                <!-- Delete uses a form with POST method for safety -->
                <form method="POST" action="{{ url_for('delete', id=entry.id) }}" class="delete-form">
                    <button type="submit" class="delete-btn">Delete</button>
                </form>
            </td>
        </tr>
        {% endfor %}
    </tbody>
</table>
<!-- Empty state shown when no transactions exist -->
{% if not entries %}
<p class="empty-state">No transactions yet. <a href="{{ url_for('add') }}">Add your first transaction</a>.</p>
{% endif %}
{% endblock %}

What does this template do?

The {% extends 'base.html' %} line tells Jinja2 to wrap this template inside the base layout. Everything inside {% block content %} replaces the matching block in the base.

The "%.2f"|format(entry.amount) filter formats numbers to two decimal places, so 50 displays as 50.00.

The delete button lives inside a <form method="POST"> because delete actions should never be triggered by a simple link click. POST requests prevent accidental deletions from bots or prefetching.

  • Save templates/index.html.
  • Refresh your browser at http://127.0.0.1:5000. You should see a dark header with "Checkbook Register", navigation links, and a message saying "No transactions yet." The page is unstyled for now.

Seeing a blank page or error?

Make sure you saved both base.html and index.html. The index template depends on the base layout existing.

Check that index.html starts with {% extends 'base.html' %} on the very first line with no blank lines above it.

Help me debug why my Flask template is not rendering.

Now you need the form template so learners can add transactions. This template provides input fields for date, check number, description, amount, and transaction type.

  • Open templates/add.html and paste the following code:
{% extends 'base.html' %}

{% block content %}
<h2>Add Transaction</h2>
<form method="POST" class="transaction-form">
    <div class="form-group">
        <label for="date">Date</label>
        <input type="date" id="date" name="date" required>
    </div>
    <div class="form-group">
        <label for="check_number">Check # (optional)</label>
        <input type="text" id="check_number" name="check_number">
    </div>
    <div class="form-group">
        <label for="description">Description</label>
        <input type="text" id="description" name="description" required>
    </div>
    <div class="form-group">
        <label for="amount">Amount</label>
        <input type="number" id="amount" name="amount" step="0.01" min="0.01" required>
    </div>
    <div class="form-group">
        <label>Type</label>
        <!-- Radio buttons default to Withdrawal -->
        <div class="radio-group">
            <label><input type="radio" name="transaction_type" value="withdrawal" checked> Withdrawal</label>
            <label><input type="radio" name="transaction_type" value="deposit"> Deposit</label>
        </div>
    </div>
    <button type="submit" class="submit-btn">Add Transaction</button>
</form>
{% endblock %}

What does this form do?

Each name attribute on the inputs matches the keys your add() route reads from request.form. For example, name="date" maps to request.form['date'] in your Python code.

The required attribute tells the browser to block form submission if those fields are empty. This gives you client-side validation on top of the server-side checks in your route.

  • Save templates/add.html.
  • In your browser, click the Add Transaction link in the navigation. You should see a form with fields for Date, Check #, Description, Amount, and radio buttons for Withdrawal/Deposit.

Form not showing up?

Make sure add.html starts with {% extends 'base.html' %} and wraps content inside {% block content %} and {% endblock %}.

Check that the URL in your browser is http://127.0.0.1:5000/add. The route only responds at /add.

Help me debug my add transaction form template.

Style the register with CSS

The templates render but everything looks plain. A stylesheet transforms the register into a clean ledger with a dark header, alternating row colors, and well-spaced form inputs.

  • Open static/style.css and paste the following styles:
/* Reset default browser spacing */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: #f5f5f5;
    color: #333;
    line-height: 1.6;
}

/* Dark header bar with app title and nav */
header {
    background-color: #2c3e50;
    color: white;
    padding: 1rem 2rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

header h1 {
    font-size: 1.5rem;
}

nav a {
    color: white;
    text-decoration: none;
    margin-left: 1rem;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    background-color: #34495e;
}

nav a:hover {
    background-color: #4a6fa5;
}

/* Center content with max width */
main {
    max-width: 960px;
    margin: 2rem auto;
    padding: 0 1rem;
}

/* Flash message styling */
.flash-messages {
    margin-bottom: 1rem;
}

.flash {
    background-color: #d4edda;
    border: 1px solid #c3e6cb;
    color: #155724;
    padding: 0.75rem 1rem;
    border-radius: 4px;
    margin-bottom: 0.5rem;
}

/* Register table with ledger appearance */
table {
    width: 100%;
    border-collapse: collapse;
    background-color: white;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    border-radius: 4px;
    overflow: hidden;
}

thead {
    background-color: #2c3e50;
    color: white;
}

th, td {
    padding: 0.75rem 1rem;
    text-align: left;
}

/* Alternating row colors for readability */
tbody tr:nth-child(even) {
    background-color: #f8f9fa;
}

tbody tr:hover {
    background-color: #e9ecef;
}

/* Balance color classes used by JavaScript */
.balance-negative {
    color: #dc3545;
    font-weight: bold;
}

.balance-positive {
    color: #28a745;
    font-weight: bold;
}

/* Action links and delete button in each row */
.actions {
    display: flex;
    gap: 0.5rem;
    align-items: center;
}

.actions a {
    color: #4a6fa5;
    text-decoration: none;
}

.actions a:hover {
    text-decoration: underline;
}

.delete-form {
    display: inline;
}

.delete-btn {
    background: none;
    border: none;
    color: #dc3545;
    cursor: pointer;
    font-size: 0.9rem;
}

.delete-btn:hover {
    text-decoration: underline;
}

/* Form card for add/edit pages */
.transaction-form {
    background-color: white;
    padding: 2rem;
    border-radius: 4px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    max-width: 500px;
}

.form-group {
    margin-bottom: 1rem;
}

.form-group label {
    display: block;
    margin-bottom: 0.25rem;
    font-weight: 600;
}

.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="number"] {
    width: 100%;
    padding: 0.5rem;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 1rem;
}

.radio-group {
    display: flex;
    gap: 1rem;
}

.radio-group label {
    font-weight: normal;
}

.submit-btn {
    background-color: #2c3e50;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
}

.submit-btn:hover {
    background-color: #4a6fa5;
}

h2 {
    margin-bottom: 1rem;
}

/* Empty state message when no transactions exist */
.empty-state {
    text-align: center;
    padding: 2rem;
    color: #666;
}

.empty-state a {
    color: #4a6fa5;
}

What do these styles do?

The header section creates a dark navigation bar matching the one in your base template. The nav a styles turn links into pill-shaped buttons.

The table styles give the register a ledger feel with alternating row colors (nth-child(even)) and a hover highlight. The .balance-negative and .balance-positive classes are prepared for JavaScript in the next step.

The form styles create a contained card with consistent spacing between fields, so the add and edit forms look professional without extra effort.

  • Save static/style.css.
  • Refresh your browser at http://127.0.0.1:5000. You should see a dark blue header bar, styled navigation buttons, and the "No transactions yet" message centered below.
  • Click Add Transaction in the nav. The form should appear inside a white card with rounded corners and bold labels.

Styles not showing up?

Make sure style.css is inside the static/ folder, not templates/. Flask serves static files from static/ specifically.

Try a hard refresh in your browser: Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows). Browsers cache CSS files, so a regular refresh may show stale styles.

Help me figure out why my Flask CSS is not loading.

\u2714\ufe0f Awesome, I've got everything!

Great. Double check you have saved all three template files and the stylesheet.

\u24e7 I'd like to double check the full code

Here is the full code for each file at the end of this step:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Checkbook Register</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
    <header>
        <h1>Checkbook Register</h1>
        <nav>
            <a href="{{ url_for('index') }}">Register</a>
            <a href="{{ url_for('add') }}">Add Transaction</a>
        </nav>
    </header>
    <main>
        {% with messages = get_flashed_messages() %}
            {% if messages %}
                <div class="flash-messages">
                    {% for message in messages %}
                        <p class="flash">{{ message }}</p>
                    {% endfor %}
                </div>
            {% endif %}
        {% endwith %}
        {% block content %}{% endblock %}
    </main>
    <script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>
{% extends 'base.html' %}

{% block content %}
<table>
    <thead>
        <tr>
            <th>Date</th>
            <th>Check #</th>
            <th>Description</th>
            <th>Withdrawal</th>
            <th>Deposit</th>
            <th>Balance</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody>
        {% for entry in entries %}
        <tr>
            <td>{{ entry.date }}</td>
            <td>{{ entry.check_number }}</td>
            <td>{{ entry.description }}</td>
            <td>{% if entry.transaction_type == 'withdrawal' %}${{ "%.2f"|format(entry.amount) }}{% endif %}</td>
            <td>{% if entry.transaction_type == 'deposit' %}${{ "%.2f"|format(entry.amount) }}{% endif %}</td>
            <td class="balance" data-balance="{{ entry.balance }}">${{ "%.2f"|format(entry.balance) }}</td>
            <td class="actions">
                <a href="{{ url_for('edit', id=entry.id) }}">Edit</a>
                <form method="POST" action="{{ url_for('delete', id=entry.id) }}" class="delete-form">
                    <button type="submit" class="delete-btn">Delete</button>
                </form>
            </td>
        </tr>
        {% endfor %}
    </tbody>
</table>
{% if not entries %}
<p class="empty-state">No transactions yet. <a href="{{ url_for('add') }}">Add your first transaction</a>.</p>
{% endif %}
{% endblock %}
{% extends 'base.html' %}

{% block content %}
<h2>Add Transaction</h2>
<form method="POST" class="transaction-form">
    <div class="form-group">
        <label for="date">Date</label>
        <input type="date" id="date" name="date" required>
    </div>
    <div class="form-group">
        <label for="check_number">Check # (optional)</label>
        <input type="text" id="check_number" name="check_number">
    </div>
    <div class="form-group">
        <label for="description">Description</label>
        <input type="text" id="description" name="description" required>
    </div>
    <div class="form-group">
        <label for="amount">Amount</label>
        <input type="number" id="amount" name="amount" step="0.01" min="0.01" required>
    </div>
    <div class="form-group">
        <label>Type</label>
        <div class="radio-group">
            <label><input type="radio" name="transaction_type" value="withdrawal" checked> Withdrawal</label>
            <label><input type="radio" name="transaction_type" value="deposit"> Deposit</label>
        </div>
    </div>
    <button type="submit" class="submit-btn">Add Transaction</button>
</form>
{% endblock %}
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: #f5f5f5;
    color: #333;
    line-height: 1.6;
}

header {
    background-color: #2c3e50;
    color: white;
    padding: 1rem 2rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

header h1 {
    font-size: 1.5rem;
}

nav a {
    color: white;
    text-decoration: none;
    margin-left: 1rem;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    background-color: #34495e;
}

nav a:hover {
    background-color: #4a6fa5;
}

main {
    max-width: 960px;
    margin: 2rem auto;
    padding: 0 1rem;
}

.flash-messages {
    margin-bottom: 1rem;
}

.flash {
    background-color: #d4edda;
    border: 1px solid #c3e6cb;
    color: #155724;
    padding: 0.75rem 1rem;
    border-radius: 4px;
    margin-bottom: 0.5rem;
}

table {
    width: 100%;
    border-collapse: collapse;
    background-color: white;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    border-radius: 4px;
    overflow: hidden;
}

thead {
    background-color: #2c3e50;
    color: white;
}

th, td {
    padding: 0.75rem 1rem;
    text-align: left;
}

tbody tr:nth-child(even) {
    background-color: #f8f9fa;
}

tbody tr:hover {
    background-color: #e9ecef;
}

.balance-negative {
    color: #dc3545;
    font-weight: bold;
}

.balance-positive {
    color: #28a745;
    font-weight: bold;
}

.actions {
    display: flex;
    gap: 0.5rem;
    align-items: center;
}

.actions a {
    color: #4a6fa5;
    text-decoration: none;
}

.actions a:hover {
    text-decoration: underline;
}

.delete-form {
    display: inline;
}

.delete-btn {
    background: none;
    border: none;
    color: #dc3545;
    cursor: pointer;
    font-size: 0.9rem;
}

.delete-btn:hover {
    text-decoration: underline;
}

.transaction-form {
    background-color: white;
    padding: 2rem;
    border-radius: 4px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    max-width: 500px;
}

.form-group {
    margin-bottom: 1rem;
}

.form-group label {
    display: block;
    margin-bottom: 0.25rem;
    font-weight: 600;
}

.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="number"] {
    width: 100%;
    padding: 0.5rem;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 1rem;
}

.radio-group {
    display: flex;
    gap: 1rem;
}

.radio-group label {
    font-weight: normal;
}

.submit-btn {
    background-color: #2c3e50;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
}

.submit-btn:hover {
    background-color: #4a6fa5;
}

h2 {
    margin-bottom: 1rem;
}

.empty-state {
    text-align: center;
    padding: 2rem;
    color: #666;
}

.empty-state a {
    color: #4a6fa5;
}

Your register has a proper frontend now. Next up, you will add the edit and delete functionality plus JavaScript that highlights balances in color.

Add Edit, Delete, and JavaScript Interactivity

Your register can display transactions and add new ones. However, mistakes happen. You need a way to fix a wrong amount or remove a duplicate entry.

In this step, you will complete the CRUD cycle by adding edit and delete routes. You will also add JavaScript that highlights balances in color and confirms before deleting.

In this step, get ready to:
  • Create an edit route and pre-filled form template.
  • Create a delete route that removes transactions.
  • Add JavaScript for balance highlighting, date defaults, and delete confirmation.
Create the edit route and template

The edit route needs to do two things. On a GET request, it loads the existing transaction from the database and shows a pre-filled form. On a POST request, it validates the updated data and saves it back.

  • In app.py, scroll to the bottom of the file (below the add() function).
  • Add the edit route by pasting this code at the bottom of the file:
@app.route('/edit/<int:id>', methods=['GET', 'POST'])
def edit(id):
    db = get_db()
    transaction = db.execute(
        'SELECT id, date, check_number, description, amount, transaction_type'
        ' FROM transactions WHERE id = ?', (id,)
    ).fetchone()

    if transaction is None:
        flash('Transaction not found.')
        return redirect(url_for('index'))

    if request.method == 'POST':
        date = request.form['date']
        check_number = request.form['check_number']
        description = request.form['description']
        amount = request.form['amount']
        transaction_type = request.form['transaction_type']
        error = None

        if not date:
            error = 'Date is required.'
        elif not description:
            error = 'Description is required.'
        elif not amount:
            error = 'Amount is required.'

        if error is None:
            db.execute(
                'UPDATE transactions SET date = ?, check_number = ?, description = ?,'
                ' amount = ?, transaction_type = ? WHERE id = ?',
                (date, check_number, description, float(amount), transaction_type, id)
            )
            db.commit()
            flash('Transaction updated successfully.')
            return redirect(url_for('index'))

        flash(error)

    return render_template('edit.html', transaction=transaction)

What does this code do?

The route uses <int:id> in the URL to capture which transaction the learner wants to edit. Flask converts this to a Python integer automatically.

  • fetchone() loads a single row from the database matching that ID.
  • If the transaction does not exist, the route flashes an error and redirects home.
  • On POST, the same validation logic from the add route runs, then an UPDATE SQL statement saves the changes.
  • render_template('edit.html', transaction=transaction) passes the existing data so the form can be pre-filled.

Now you need the template that displays the pre-filled form. The edit template looks almost identical to the add template, but each input has a value attribute pulling from the database.

  • Open templates/edit.html in VS Code (the file is currently empty).
  • Paste the following template code:
{% extends 'base.html' %}

{% block content %}
<h2>Edit Transaction</h2>
<form method="POST" class="transaction-form">
    <div class="form-group">
        <label for="date">Date</label>
        <input type="date" id="date" name="date" value="{{ transaction['date'] }}" required>
    </div>
    <div class="form-group">
        <label for="check_number">Check # (optional)</label>
        <input type="text" id="check_number" name="check_number" value="{{ transaction['check_number'] or '' }}">
    </div>
    <div class="form-group">
        <label for="description">Description</label>
        <input type="text" id="description" name="description" value="{{ transaction['description'] }}" required>
    </div>
    <div class="form-group">
        <label for="amount">Amount</label>
        <input type="number" id="amount" name="amount" step="0.01" min="0.01" value="{{ transaction['amount'] }}" required>
    </div>
    <div class="form-group">
        <label>Type</label>
        <div class="radio-group">
            <label><input type="radio" name="transaction_type" value="withdrawal" {% if transaction['transaction_type'] == 'withdrawal' %}checked{% endif %}> Withdrawal</label>
            <label><input type="radio" name="transaction_type" value="deposit" {% if transaction['transaction_type'] == 'deposit' %}checked{% endif %}> Deposit</label>
        </div>
    </div>
    <button type="submit" class="submit-btn">Update Transaction</button>
</form>
{% endblock %}

How does the pre-filling work?

Each input's value attribute uses Jinja2 syntax like {{ transaction['date'] }} to insert the existing value from the database.

The radio buttons use {% if %} blocks to add the checked attribute to whichever option matches the stored transaction type.

  • Save app.py and templates/edit.html.
  • Return to your browser at http://127.0.0.1:5000 and click Edit next to any transaction.
  • You should see the Edit Transaction form with all fields pre-filled with the existing values.

Seeing a BuildError or 404?

  • Make sure the route decorator is exactly @app.route('/edit/<int:id>', methods=['GET', 'POST']). A typo in the URL pattern will cause a 404.
  • Check that Flask's debug server reloaded after you saved. You should see a "Restarting with stat" message in your terminal.
  • If you have no transactions to edit, add one first using the Add Transaction page.

Help me debug my Flask edit route

Create the delete route

Deleting a transaction is simpler than editing. The route only accepts POST requests (to prevent accidental deletion via a browser bookmark or link). It removes the row and redirects back to the register.

  • In app.py, add the delete route below the edit() function you just wrote:
@app.route('/delete/<int:id>', methods=['POST'])
def delete(id):
    db = get_db()
    db.execute('DELETE FROM transactions WHERE id = ?', (id,))
    db.commit()
    flash('Transaction deleted.')
    return redirect(url_for('index'))

Why POST-only for delete?

Using methods=['POST'] means a browser cannot accidentally trigger deletion by following a link. The delete buttons in your index.html template use a small <form> with method="POST" to send the request safely.

  • Save app.py.
  • In your browser, click Delete next to a transaction. You should see the transaction disappear and a green "Transaction deleted." flash message at the top.

Delete button not working?

  • Check that the route uses methods=['POST'] (not 'GET'). The delete form in your template sends a POST request.
  • Make sure your index.html has the delete form with action="{{ url_for('delete', id=entry.id) }}" on each row.

Help me fix my delete route

Add JavaScript for interactivity

Your register works, but the balance column is plain text. JavaScript can scan the balance cells and apply color. It can also auto-fill today's date when adding transactions and ask for confirmation before deleting.

  • Open static/app.js in VS Code (currently empty).
  • Paste the following JavaScript:
document.addEventListener('DOMContentLoaded', function() {
    // Highlight balances based on positive/negative
    var balanceCells = document.querySelectorAll('.balance');
    for (var i = 0; i < balanceCells.length; i++) {
        var balance = parseFloat(balanceCells[i].getAttribute('data-balance'));
        if (balance < 0) {
            balanceCells[i].classList.add('balance-negative');
        } else {
            balanceCells[i].classList.add('balance-positive');
        }
    }

    // Set today's date as default for date inputs
    var dateInputs = document.querySelectorAll('input[type="date"]');
    for (var i = 0; i < dateInputs.length; i++) {
        if (!dateInputs[i].value) {
            var today = new Date();
            var year = today.getFullYear();
            var month = String(today.getMonth() + 1).padStart(2, '0');
            var day = String(today.getDate()).padStart(2, '0');
            dateInputs[i].value = year + '-' + month + '-' + day;
        }
    }

    // Confirm before delete
    var deleteForms = document.querySelectorAll('.delete-form');
    for (var i = 0; i < deleteForms.length; i++) {
        deleteForms[i].addEventListener('submit', function(e) {
            if (!confirm('Are you sure you want to delete this transaction?')) {
                e.preventDefault();
            }
        });
    }
});

What does this JavaScript do?

  • Balance highlighting: reads the data-balance attribute from each balance cell. If negative, it adds the balance-negative CSS class (red). Otherwise it adds balance-positive (green).
  • Date auto-fill: finds empty date inputs and sets them to today's date. This saves time when adding new transactions.
  • Delete confirmation: intercepts delete form submissions and shows a browser confirmation dialog. If the user cancels, e.preventDefault() stops the form from submitting.
  • Save static/app.js.
  • Refresh your browser at http://127.0.0.1:5000. You should see the Balance column values colored green (positive) or red (negative).
  • Click Add Transaction in the navigation. The date field should already show today's date.
  • Go back to the register and click Delete on a transaction. A browser dialog should ask "Are you sure you want to delete this transaction?" before proceeding.

Balances not turning green or red?

  • Open your browser's developer tools (press F12) and check the Console tab for JavaScript errors.
  • Verify that your index.html template has class="balance" and data-balance="{{ entry.balance }}" on the balance <td> cells. The JavaScript targets these attributes.
  • Do a hard refresh (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear cached JavaScript.

Help me debug my JavaScript balance highlighting

✔️ Awesome, I've got everything!

Great. Double check that you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py with all routes:

import sqlite3
import os

import click
from flask import Flask, render_template, request, redirect, url_for, flash, g

app = Flask(__name__)
app.config['DATABASE'] = os.path.join(app.instance_path, 'checkbook.db')
app.config['SECRET_KEY'] = 'dev'

os.makedirs(app.instance_path, exist_ok=True)


def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(
            app.config['DATABASE'],
            detect_types=sqlite3.PARSE_DECLTYPES
        )
        g.db.row_factory = sqlite3.Row
    return g.db


def close_db(e=None):
    db = g.pop('db', None)
    if db is not None:
        db.close()


def init_db():
    db = get_db()
    with app.open_resource('schema.sql') as f:
        db.executescript(f.read().decode('utf8'))


@click.command('init-db')
def init_db_command():
    """Clear the existing data and create new tables."""
    init_db()
    click.echo('Initialized the database.')


app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)


@app.route('/')
def index():
    db = get_db()
    transactions = db.execute(
        'SELECT id, date, check_number, description, amount, transaction_type'
        ' FROM transactions ORDER BY date ASC, id ASC'
    ).fetchall()

    running_balance = 0.0
    entries = []
    for txn in transactions:
        if txn['transaction_type'] == 'deposit':
            running_balance += txn['amount']
        else:
            running_balance -= txn['amount']
        entries.append({
            'id': txn['id'],
            'date': txn['date'],
            'check_number': txn['check_number'],
            'description': txn['description'],
            'amount': txn['amount'],
            'transaction_type': txn['transaction_type'],
            'balance': running_balance
        })

    return render_template('index.html', entries=entries)


@app.route('/add', methods=['GET', 'POST'])
def add():
    if request.method == 'POST':
        date = request.form['date']
        check_number = request.form['check_number']
        description = request.form['description']
        amount = request.form['amount']
        transaction_type = request.form['transaction_type']
        error = None

        if not date:
            error = 'Date is required.'
        elif not description:
            error = 'Description is required.'
        elif not amount:
            error = 'Amount is required.'

        if error is None:
            db = get_db()
            db.execute(
                'INSERT INTO transactions (date, check_number, description, amount, transaction_type)'
                ' VALUES (?, ?, ?, ?, ?)',
                (date, check_number, description, float(amount), transaction_type)
            )
            db.commit()
            flash('Transaction added successfully.')
            return redirect(url_for('index'))

        flash(error)

    return render_template('add.html')


@app.route('/edit/<int:id>', methods=['GET', 'POST'])
def edit(id):
    db = get_db()
    transaction = db.execute(
        'SELECT id, date, check_number, description, amount, transaction_type'
        ' FROM transactions WHERE id = ?', (id,)
    ).fetchone()

    if transaction is None:
        flash('Transaction not found.')
        return redirect(url_for('index'))

    if request.method == 'POST':
        date = request.form['date']
        check_number = request.form['check_number']
        description = request.form['description']
        amount = request.form['amount']
        transaction_type = request.form['transaction_type']
        error = None

        if not date:
            error = 'Date is required.'
        elif not description:
            error = 'Description is required.'
        elif not amount:
            error = 'Amount is required.'

        if error is None:
            db.execute(
                'UPDATE transactions SET date = ?, check_number = ?, description = ?,'
                ' amount = ?, transaction_type = ? WHERE id = ?',
                (date, check_number, description, float(amount), transaction_type, id)
            )
            db.commit()
            flash('Transaction updated successfully.')
            return redirect(url_for('index'))

        flash(error)

    return render_template('edit.html', transaction=transaction)


@app.route('/delete/<int:id>', methods=['POST'])
def delete(id):
    db = get_db()
    db.execute('DELETE FROM transactions WHERE id = ?', (id,))
    db.commit()
    flash('Transaction deleted.')
    return redirect(url_for('index'))

Here is the complete templates/edit.html:

{% extends 'base.html' %}

{% block content %}
<h2>Edit Transaction</h2>
<form method="POST" class="transaction-form">
    <div class="form-group">
        <label for="date">Date</label>
        <input type="date" id="date" name="date" value="{{ transaction['date'] }}" required>
    </div>
    <div class="form-group">
        <label for="check_number">Check # (optional)</label>
        <input type="text" id="check_number" name="check_number" value="{{ transaction['check_number'] or '' }}">
    </div>
    <div class="form-group">
        <label for="description">Description</label>
        <input type="text" id="description" name="description" value="{{ transaction['description'] }}" required>
    </div>
    <div class="form-group">
        <label for="amount">Amount</label>
        <input type="number" id="amount" name="amount" step="0.01" min="0.01" value="{{ transaction['amount'] }}" required>
    </div>
    <div class="form-group">
        <label>Type</label>
        <div class="radio-group">
            <label><input type="radio" name="transaction_type" value="withdrawal" {% if transaction['transaction_type'] == 'withdrawal' %}checked{% endif %}> Withdrawal</label>
            <label><input type="radio" name="transaction_type" value="deposit" {% if transaction['transaction_type'] == 'deposit' %}checked{% endif %}> Deposit</label>
        </div>
    </div>
    <button type="submit" class="submit-btn">Update Transaction</button>
</form>
{% endblock %}

Here is the complete static/app.js:

document.addEventListener('DOMContentLoaded', function() {
    // Highlight balances based on positive/negative
    var balanceCells = document.querySelectorAll('.balance');
    for (var i = 0; i < balanceCells.length; i++) {
        var balance = parseFloat(balanceCells[i].getAttribute('data-balance'));
        if (balance < 0) {
            balanceCells[i].classList.add('balance-negative');
        } else {
            balanceCells[i].classList.add('balance-positive');
        }
    }

    // Set today's date as default for date inputs
    var dateInputs = document.querySelectorAll('input[type="date"]');
    for (var i = 0; i < dateInputs.length; i++) {
        if (!dateInputs[i].value) {
            var today = new Date();
            var year = today.getFullYear();
            var month = String(today.getMonth() + 1).padStart(2, '0');
            var day = String(today.getDate()).padStart(2, '0');
            dateInputs[i].value = year + '-' + month + '-' + day;
        }
    }

    // Confirm before delete
    var deleteForms = document.querySelectorAll('.delete-form');
    for (var i = 0; i < deleteForms.length; i++) {
        deleteForms[i].addEventListener('submit', function(e) {
            if (!confirm('Are you sure you want to delete this transaction?')) {
                e.preventDefault();
            }
        });
    }
});

Your checkbook register now has full CRUD functionality. You can create, read, update, and delete transactions, with JavaScript adding polish through color-coded balances and safety confirmations.

Secret mission

Category Tagging and Spending Dashboard

Your checkbook tracks money in and out, but where is it all going? In this extension, you'll add category tagging to every transaction and build a spending dashboard that breaks down your expenses by category.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your machine, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The checkbook project folder on your Desktop (contains all application code, templates, and static files).
  • Python virtual environment (.venv folder inside your project).
  • SQLite database file (instance/checkbook.db).
  • Flask development server (running process in your terminal).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with your checkbook register.

  • Your Flask dev server continues running at http://127.0.0.1:5000.
  • Your database retains all transactions you have entered.
  • You can resume editing files in VS Code at any time.

Pause - I'll come back to this later

Stop the running server to free up your terminal, but keep all project files on disk so you can pick up where you left off.

  • In the terminal where Flask is running, press Ctrl+C to stop the dev server.
  • Deactivate your virtual environment by running:
deactivate

When you are ready to resume, open the terminal in your checkbook folder, reactivate the virtual environment, and start the server again.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes your code, database, and virtual environment permanently.

  • In the terminal where Flask is running, press Ctrl+C to stop the dev server.
  • Deactivate your virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the entire project folder by running:

macOS

cd ~/Desktop
rm -rf checkbook

Windows

cd ~/Desktop
rmdir /s /q checkbook

This removes all project files including app.py, your templates, static assets, the .venv virtual environment, and the instance/checkbook.db database.

Nice Work!

Nice Work!

You just built a fully functional checkbook register web application from scratch using Python and Flask. Your app runs locally in the browser with a real database behind it, full CRUD functionality, and a clean ledger-style interface.

You've learned how to:

  • Build a Flask web application with routes that handle GET and POST requests for viewing, adding, editing, and deleting transactions.
  • Create a SQLite database from a schema file and perform full CRUD operations using raw SQL queries in Python.
  • Build a polished frontend with Jinja2 template inheritance, CSS ledger styling, and JavaScript interactivity for balance highlighting and delete confirmations.
  • Secret Mission: Added category tracking to every transaction and built a spending dashboard that groups expenses by category with income and expense totals.

Ready to quiz yourself?