Back to Go Examples

Project: Mortgage Calculator

A live web app that calculates monthly payments and saves every result to SQLite — built with nothing but Go's standard library and one pure-Go SQLite driver.

Contents

  1. Live Demo
  2. What It Does
  3. The Stack
  4. The Math
  5. The Complete Code
  6. How It Works
  7. Run It Yourself

1. Live Demo

The app is running on this server right now. Open it, enter some numbers, and watch the results appear in the table below the form — each row is a real SQLite row.

→ Open the Mortgage Calculator

2. What It Does

All data lives in a single file: mortgage.db in the working directory. SQLite creates it automatically if it doesn't exist.

3. The Stack

4. The Math

The standard fixed-rate amortization formula:

M = P · r · (1 + r)⊃n
        —————————————
            (1 + r)⊃n − 1

P = loan principal (home price − down payment)
r = monthly interest rate (annual rate ÷ 12 ÷ 100)
n = total payments (years × 12)
M = monthly payment

For example: $320,000 loan at 6.875% for 30 years:

r = 6.875 / 100 / 12 = 0.005729...
n = 360
factor = (1.005729)^360 = 7.777...
M = 320000 × 0.005729 × 7.777 / (7.777 − 1) = $2,104.04/month

Go code:

func calcPayment(principal, annualRate float64, termYears int) (monthly, totalInterest, totalCost float64) {
    n := float64(termYears * 12)
    r := annualRate / 100 / 12
    factor := math.Pow(1+r, n)
    monthly = principal * r * factor / (factor - 1)
    monthly = math.Round(monthly*100) / 100    // round to cents
    totalCost = math.Round(monthly*n*100) / 100
    totalInterest = math.Round((totalCost-principal)*100) / 100
    return
}

5. The Complete Code

One file: main.go. The HTML template and CSS are embedded as string constants at the bottom.

package main

import (
    "database/sql"
    "fmt"
    "html/template"
    "log"
    "math"
    "net/http"
    "strconv"
    "time"

    _ "modernc.org/sqlite"
)

const (
    listenAddr = "127.0.0.1:8082"
    basePath   = "/goapp/mortgage"
    dbPath     = "./mortgage.db"
)

var (
    db   *sql.DB
    page *template.Template
)

type Calc struct {
    ID             int64
    HomePrice      float64
    DownPayment    float64
    LoanAmount     float64
    AnnualRate     float64
    TermYears      int
    MonthlyPayment float64
    TotalInterest  float64
    TotalCost      float64
    CreatedAt      string
}

type pageData struct {
    BasePath template.URL   // template.URL marks it as a trusted URL — not re-encoded
    Calcs    []Calc
    Error    string
}

func initDB() error {
    var err error
    db, err = sql.Open("sqlite", dbPath)
    if err != nil { return err }
    _, err = db.Exec(`CREATE TABLE IF NOT EXISTS calculations (
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
        home_price      REAL    NOT NULL,
        down_payment    REAL    NOT NULL,
        loan_amount     REAL    NOT NULL,
        annual_rate     REAL    NOT NULL,
        term_years      INTEGER NOT NULL,
        monthly_payment REAL    NOT NULL,
        total_interest  REAL    NOT NULL,
        total_cost      REAL    NOT NULL,
        created_at      TEXT    DEFAULT (datetime('now'))
    )`)
    return err
}

func calcPayment(principal, annualRate float64, termYears int) (monthly, totalInterest, totalCost float64) {
    n := float64(termYears * 12)
    if annualRate == 0 {
        monthly = principal / n
    } else {
        r := annualRate / 100 / 12
        factor := math.Pow(1+r, n)
        monthly = principal * r * factor / (factor - 1)
    }
    monthly       = math.Round(monthly*100) / 100
    totalCost     = math.Round(monthly*n*100) / 100
    totalInterest = math.Round((totalCost-principal)*100) / 100
    return
}

// commaInt formats an integer with thousands separators: 1234567 → "1,234,567"
func commaInt(n int64) string {
    s := fmt.Sprintf("%d", n)
    length := len(s)
    if length <= 3 { return s }
    out := make([]byte, 0, length+(length-1)/3)
    for i, c := range s {
        if i > 0 && (length-i)%3 == 0 { out = append(out, ',') }
        out = append(out, byte(c))
    }
    return string(out)
}

func dollars(v float64) string { return "$" + commaInt(int64(math.Round(v))) }

func payment(v float64) string {
    whole := int64(math.Floor(v))
    cents := int(math.Round((v - float64(whole)) * 100))
    return "$" + commaInt(whole) + fmt.Sprintf(".%02d", cents)
}

func loadCalcs() ([]Calc, error) {
    rows, err := db.Query(`SELECT id, home_price, down_payment, loan_amount, annual_rate,
        term_years, monthly_payment, total_interest, total_cost, created_at
        FROM calculations ORDER BY id DESC`)
    if err != nil { return nil, err }
    defer rows.Close()
    var calcs []Calc
    for rows.Next() {
        var c Calc; var rawTime string
        rows.Scan(&c.ID, &c.HomePrice, &c.DownPayment, &c.LoanAmount,
            &c.AnnualRate, &c.TermYears, &c.MonthlyPayment,
            &c.TotalInterest, &c.TotalCost, &rawTime)
        if t, err := time.Parse("2006-01-02 15:04:05", rawTime); err == nil {
            c.CreatedAt = t.Format("Jan 2, 2006 15:04 UTC")
        }
        calcs = append(calcs, c)
    }
    return calcs, rows.Err()
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" { http.NotFound(w, r); return }
    calcs, _ := loadCalcs()
    page.Execute(w, pageData{BasePath: template.URL(basePath), Calcs: calcs})
}

func calcHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Redirect(w, r, basePath+"/", http.StatusSeeOther); return
    }
    r.ParseForm()
    homePrice,  e1 := strconv.ParseFloat(r.FormValue("home_price"),  64)
    downPayment, e2 := strconv.ParseFloat(r.FormValue("down_payment"), 64)
    annualRate,  e3 := strconv.ParseFloat(r.FormValue("annual_rate"),  64)
    termYears,   e4 := strconv.Atoi(r.FormValue("term_years"))

    showErr := func(msg string) {
        calcs, _ := loadCalcs()
        page.Execute(w, pageData{BasePath: template.URL(basePath), Calcs: calcs, Error: msg})
    }
    if e1 != nil || e2 != nil || e3 != nil || e4 != nil ||
        homePrice <= 0 || downPayment < 0 || annualRate < 0 || termYears <= 0 {
        showErr("Invalid input — please check your values."); return
    }
    if downPayment >= homePrice {
        showErr("Down payment must be less than the home price."); return
    }
    loan := homePrice - downPayment
    monthly, totalInterest, totalCost := calcPayment(loan, annualRate, termYears)
    db.Exec(`INSERT INTO calculations
        (home_price, down_payment, loan_amount, annual_rate, term_years,
         monthly_payment, total_interest, total_cost)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
        homePrice, downPayment, loan, annualRate, termYears,
        monthly, totalInterest, totalCost)
    http.Redirect(w, r, basePath+"/", http.StatusSeeOther)
}

func deleteHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodPost {
        db.Exec("DELETE FROM calculations WHERE id = ?", r.FormValue("id"))
    }
    http.Redirect(w, r, basePath+"/", http.StatusSeeOther)
}

func main() {
    if err := initDB(); err != nil { log.Fatal("initDB:", err) }
    defer db.Close()

    page = template.Must(template.New("page").Funcs(template.FuncMap{
        "dollars": dollars, "payment": payment,
    }).Parse(pageTmplStr))

    mux := http.NewServeMux()
    mux.HandleFunc("/",          homeHandler)
    mux.HandleFunc("/calculate", calcHandler)
    mux.HandleFunc("/delete",    deleteHandler)
    mux.HandleFunc("/style.css", styleHandler)

    srv := &http.Server{Addr: listenAddr, Handler: mux,
        ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
    log.Printf("mortgage: listening on %s", listenAddr)
    log.Fatal(srv.ListenAndServe())
}

6. How It Works

  1. The database is opened once at startup. sql.Open returns a connection pool, not a single connection. initDB creates the table if it doesn't exist, so the app is self-bootstrapping — no migration step needed.
  2. html/template is safe by default. Every {{.Field}} is escaped based on context. A field rendered inside an href attribute gets URL-encoded; one in HTML body text gets HTML-escaped. You can't accidentally inject a <script> tag through a form field. This is the key difference from text/template.
  3. Template functions let you format values. dollars and payment are registered via template.FuncMap and called in the template as {{dollars .HomePrice}} and {{payment .MonthlyPayment}}. The template system is intentionally minimal — formatting logic stays in Go, not in the template.
  4. Post-Redirect-Get prevents double-submit. The form POSTs to /calculate. After saving to the database, the handler returns a 303 See Other redirect to /. The browser follows the redirect with a GET. If the user presses refresh, they re-run the GET — not the POST.
  5. template.URL(basePath) marks a value as a trusted URL. If BasePath were a plain string, html/template would treat it as untrusted and might rewrite it when used in an href or action attribute. Wrapping it in template.URL tells the template engine it's already a safe, known URL path.
  6. Delete uses POST, not GET. A GET /delete?id=1 endpoint would allow browser prefetchers and crawlers to accidentally delete rows. The table uses a small <form method="POST"> for each row instead. The handler ignores non-POST requests.
  7. SQLite is single-file, zero-config. The entire database is mortgage.db. To back it up: cp mortgage.db mortgage.bak. To inspect it: sqlite3 mortgage.db "SELECT * FROM calculations;". modernc.org/sqlite is a pure-Go port — no CGO, no gcc required.

7. Run It Yourself

$ mkdir mortgage && cd mortgage
$ go mod init mortgage
$ go get modernc.org/sqlite
# paste main.go
$ go build -o mortgage . && ./mortgage
mortgage: listening on 127.0.0.1:8082

Open http://127.0.0.1:8082 in your browser. The database file mortgage.db is created in the same directory the first time you calculate.

To inspect the raw data:

$ sqlite3 mortgage.db
sqlite> SELECT id, home_price, monthly_payment, created_at FROM calculations;
1|400000.0|2104.04|2026-05-22 14:00:00

To deploy behind nginx on Linux, see Hosting: Run a Go Web App on Linux.