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.
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.
/delete).mortgage.db in the working directory. SQLite creates it automatically if it doesn't exist.net/http — the web server, router, and request/response handling. Standard library, no framework.html/template — context-aware HTML templating. Unlike text/template, it automatically escapes values based on where they appear (HTML body, attribute, URL, CSS) — you can't accidentally produce XSS.database/sql — Go's standard database interface. Works with any driver via a blank import.modernc.org/sqlite — a pure-Go SQLite driver (no gcc needed). Imported with _ "modernc.org/sqlite" — the blank identifier means we only want the driver registration side effect.sync/atomic, math, strconv — all standard library.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
}
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())
}
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.
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.
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.
/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.
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.
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.
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.
$ 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.