Flask #

Flask is a Python micro web framework that gives developers complete freedom — no built-in ORM, no forced directory structure, no mandatory components. You assemble what you need yourself. This simplicity makes it ideal for lightweight REST APIs, microservices, quick prototypes, and applications with very specific needs. The key to understanding Flask is understanding its three core concepts: routing with decorators, request context, and the application factory pattern that enables flexible configuration and clean testing.

Installation #

pip install flask flask-sqlalchemy python-dotenv

Application Factory Pattern #

The application factory is Flask’s standard pattern for serious projects — creating the Flask instance inside a function, not at the global module level. This makes testing and multi-environment configuration easier.

# app/__init__.py

from flask import Flask
from .config import config_by_name

def create_app(config_name: str = "development") -> Flask:
    app = Flask(__name__)
    app.config.from_object(config_by_name[config_name])

    # Initialize extensions
    from .extensions import db, migrate
    db.init_app(app)
    migrate.init_app(app, db)

    # Register blueprints
    from .produk  import produk_bp
    from .auth    import auth_bp
    from .api     import api_bp

    app.register_blueprint(produk_bp,  url_prefix="/produk")
    app.register_blueprint(auth_bp,    url_prefix="/auth")
    app.register_blueprint(api_bp,     url_prefix="/api/v1")

    # Register error handlers
    register_error_handlers(app)

    return app

def register_error_handlers(app: Flask) -> None:
    from flask import jsonify

    @app.errorhandler(400)
    def bad_request(e):
        return jsonify(error="Bad Request", message=str(e)), 400

    @app.errorhandler(404)
    def not_found(e):
        return jsonify(error="Not Found", message=str(e)), 404

    @app.errorhandler(500)
    def internal_error(e):
        return jsonify(error="Internal Server Error"), 500
# app/config.py

import os
from dotenv import load_dotenv

load_dotenv()

class BaseConfig:
    SECRET_KEY          = os.getenv("SECRET_KEY", "dev-secret-change-in-production")
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    JSON_SORT_KEYS      = False

class DevelopmentConfig(BaseConfig):
    DEBUG               = True
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///dev.db")

class TestingConfig(BaseConfig):
    TESTING             = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
    WTF_CSRF_ENABLED    = False

class ProductionConfig(BaseConfig):
    DEBUG               = False
    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL")

    # ANTI-PATTERN: falling back to dev config in production
    # assert os.getenv("DATABASE_URL"), "DATABASE_URL must be set in production!"

config_by_name = {
    "development": DevelopmentConfig,
    "testing":     TestingConfig,
    "production":  ProductionConfig,
}
# app/extensions.py

from flask_sqlalchemy import SQLAlchemy
from flask_migrate    import Migrate

db      = SQLAlchemy()
migrate = Migrate()
# wsgi.py -- entry point

import os
from app import create_app

app = create_app(os.getenv("FLASK_ENV", "development"))

if __name__ == "__main__":
    app.run()
# Run with Gunicorn in production
gunicorn wsgi:app --workers 4 --bind 0.0.0.0:8000

# Development
flask --app wsgi run --debug

Models with Flask-SQLAlchemy #

# app/models.py

from .extensions import db
from datetime    import datetime, timezone
from werkzeug.security import generate_password_hash, check_password_hash

class TimestampMixin:
    """Mixin for automatic created_at and updated_at columns."""
    dibuat_pada = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
    diubah_pada = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
                            onupdate=lambda: datetime.now(timezone.utc), nullable=False)

class Kategori(TimestampMixin, db.Model):
    __tablename__ = "kategori"

    id   = db.Column(db.Integer, primary_key=True)
    nama = db.Column(db.String(100), nullable=False, unique=True)
    slug = db.Column(db.String(120), nullable=False, unique=True)

    produk = db.relationship("Produk", back_populates="kategori", lazy="dynamic")

    def to_dict(self) -> dict:
        return {"id": self.id, "nama": self.nama, "slug": self.slug}

    def __repr__(self):
        return f"<Kategori {self.nama}>"

class Produk(TimestampMixin, db.Model):
    __tablename__ = "produk"

    id          = db.Column(db.Integer, primary_key=True)
    nama        = db.Column(db.String(200), nullable=False)
    slug        = db.Column(db.String(220), nullable=False, unique=True)
    deskripsi   = db.Column(db.Text, default="")
    harga       = db.Column(db.Numeric(15, 2), nullable=False)
    stok        = db.Column(db.Integer, default=0)
    aktif       = db.Column(db.Boolean, default=True)
    kategori_id = db.Column(db.Integer, db.ForeignKey("kategori.id"), nullable=True)

    kategori = db.relationship("Kategori", back_populates="produk")

    def to_dict(self) -> dict:
        return {
            "id":           self.id,
            "nama":         self.nama,
            "slug":         self.slug,
            "deskripsi":    self.deskripsi,
            "harga":        float(self.harga),
            "stok":         self.stok,
            "aktif":        self.aktif,
            "kategori":     self.kategori.to_dict() if self.kategori else None,
            "dibuat_pada":  self.dibuat_pada.isoformat(),
        }

    def __repr__(self):
        return f"<Produk {self.nama}>"

class Pengguna(TimestampMixin, db.Model):
    __tablename__ = "pengguna"

    id            = db.Column(db.Integer, primary_key=True)
    nama          = db.Column(db.String(100), nullable=False)
    email         = db.Column(db.String(150), nullable=False, unique=True)
    password_hash = db.Column(db.String(256), nullable=False)
    aktif         = db.Column(db.Boolean, default=True)

    def set_password(self, password: str) -> None:
        self.password_hash = generate_password_hash(password)

    def check_password(self, password: str) -> bool:
        return check_password_hash(self.password_hash, password)

    def to_dict(self) -> dict:
        return {"id": self.id, "nama": self.nama, "email": self.email}

Blueprints and Routing #

Blueprints allow breaking a large application into separate modules, each with its own routes, templates, and static files.

# app/produk/__init__.py
from flask import Blueprint
produk_bp = Blueprint("produk", __name__, template_folder="templates")
from . import views   # import views so routes are registered
# app/produk/views.py

from flask              import render_template, request, redirect, url_for, flash, abort
from ..extensions       import db
from ..models           import Produk, Kategori
from . import produk_bp

@produk_bp.route("/")
def daftar():
    halaman     = request.args.get("halaman", 1, type=int)
    per_halaman = 12
    q           = request.args.get("q", "")

    query = Produk.query.filter_by(aktif=True)
    if q:
        query = query.filter(Produk.nama.ilike(f"%{q}%"))

    pagination  = query.order_by(Produk.dibuat_pada.desc()).paginate(
        page=halaman, per_page=per_halaman, error_out=False
    )

    return render_template(
        "produk/daftar.html",
        produk_list=pagination.items,
        pagination=pagination,
        q=q,
        kategori_list=Kategori.query.order_by(Kategori.nama).all()
    )

@produk_bp.route("/<slug>")
def detail(slug: str):
    produk = Produk.query.filter_by(slug=slug, aktif=True).first_or_404()
    return render_template("produk/detail.html", produk=produk)

@produk_bp.route("/tambah", methods=["GET", "POST"])
def tambah():
    if request.method == "POST":
        nama  = request.form.get("nama", "").strip()
        harga = request.form.get("harga", type=float)

        if not nama or not harga:
            flash("Name and price are required.", "error")
            return redirect(url_for("produk.tambah"))

        from slugify import slugify
        produk = Produk(nama=nama, harga=harga, slug=slugify(nama))
        db.session.add(produk)
        db.session.commit()
        flash(f"Product '{nama}' added successfully.", "success")
        return redirect(url_for("produk.detail", slug=produk.slug))

    return render_template("produk/form.html", produk=None)

REST APIs with Blueprints #

# app/api/__init__.py
from flask import Blueprint
api_bp = Blueprint("api", __name__)
from . import produk_api
# app/api/produk_api.py

from flask          import request, jsonify, abort
from ..extensions   import db
from ..models       import Produk, Kategori
from . import api_bp

@api_bp.route("/produk", methods=["GET"])
def list_produk():
    halaman     = request.args.get("halaman", 1, type=int)
    per_halaman = request.args.get("per_halaman", 20, type=int)
    per_halaman = min(per_halaman, 100)   # cap at 100

    query = Produk.query.filter_by(aktif=True)

    q = request.args.get("q")
    if q:
        query = query.filter(Produk.nama.ilike(f"%{q}%"))

    pagination = query.order_by(Produk.dibuat_pada.desc()).paginate(
        page=halaman, per_page=per_halaman, error_out=False
    )

    return jsonify({
        "total":       pagination.total,
        "halaman":     halaman,
        "per_halaman": per_halaman,
        "data":        [p.to_dict() for p in pagination.items]
    })

@api_bp.route("/produk/<int:produk_id>", methods=["GET"])
def get_produk(produk_id: int):
    produk = Produk.query.get_or_404(produk_id)
    return jsonify(produk.to_dict())

@api_bp.route("/produk", methods=["POST"])
def buat_produk():
    data = request.get_json(silent=True)

    # ANTI-PATTERN: no input validation
    # produk = Produk(**data)  # ✗ -- can inject any field

    # CORRECT: whitelist the allowed fields
    if not data:
        return jsonify(error="A JSON body is required"), 400

    nama  = data.get("nama", "").strip()
    harga = data.get("harga")

    if not nama:
        return jsonify(error="The 'nama' field is required"), 422
    if harga is None or float(harga) <= 0:
        return jsonify(error="The 'harga' field must be greater than 0"), 422

    from slugify import slugify
    produk = Produk(
        nama=nama,
        slug=slugify(nama),
        harga=harga,
        deskripsi=data.get("deskripsi", ""),
        stok=data.get("stok", 0),
        kategori_id=data.get("kategori_id")
    )
    db.session.add(produk)
    db.session.commit()

    return jsonify(produk.to_dict()), 201

@api_bp.route("/produk/<int:produk_id>", methods=["PATCH"])
def update_produk(produk_id: int):
    produk = Produk.query.get_or_404(produk_id)
    data   = request.get_json(silent=True)

    if not data:
        return jsonify(error="A JSON body is required"), 400

    # Update only the sent fields
    field_diizinkan = {"nama", "deskripsi", "harga", "stok", "aktif", "kategori_id"}
    for key, value in data.items():
        if key in field_diizinkan:
            setattr(produk, key, value)

    db.session.commit()
    return jsonify(produk.to_dict())

@api_bp.route("/produk/<int:produk_id>", methods=["DELETE"])
def hapus_produk(produk_id: int):
    produk = Produk.query.get_or_404(produk_id)
    db.session.delete(produk)
    db.session.commit()
    return "", 204

Request Hooks #

Request hooks let you run code before or after every request — suitable for authentication, logging, and connection management.

# app/api/__init__.py or in any blueprint

from flask import request, jsonify, g
import time

@api_bp.before_request
def before_api_request():
    g.start_time = time.time()   # store in g (request context)

    # Validate Content-Type for requests with a body
    if request.method in ("POST", "PUT", "PATCH"):
        if not request.is_json:
            return jsonify(error="Content-Type must be application/json"), 415

@api_bp.after_request
def after_api_request(response):
    durasi = (time.time() - g.get("start_time", time.time())) * 1000
    response.headers["X-Response-Time"] = f"{durasi:.1f}ms"
    response.headers["X-API-Version"]   = "1.0"
    return response

@api_bp.teardown_request
def teardown(exc):
    if exc:
        db.session.rollback()
    db.session.remove()

Jinja2 Templates #

<!-- app/templates/base.html -->

<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>{% block title %}MyApp{% endblock %}</title>
</head>
<body>
  <nav>
    <a href="{{ url_for('produk.daftar') }}">Products</a>
  </nav>

  {% with messages = get_flashed_messages(with_categories=true) %}
    {% for category, message in messages %}
      <div class="alert alert-{{ category }}">{{ message }}</div>
    {% endfor %}
  {% endwith %}

  <main>{% block content %}{% endblock %}</main>
</body>
</html>
<!-- app/templates/produk/daftar.html -->

{% extends "base.html" %}

{% block title %}Product List{% endblock %}

{% block content %}
<h1>Products</h1>

<form method="get">
  <input name="q" value="{{ q }}" placeholder="Search products...">
  <button>Search</button>
</form>

{% for produk in produk_list %}
  <div>
    <a href="{{ url_for('produk.detail', slug=produk.slug) }}">{{ produk.nama }}</a>
    <span>Rp{{ "{:,.0f}".format(produk.harga) }}</span>
    <span>Stock: {{ produk.stok }}</span>
  </div>
{% else %}
  <p>No products found.</p>
{% endfor %}

{# Pagination #}
{% if pagination.pages > 1 %}
  {% if pagination.has_prev %}
    <a href="{{ url_for('produk.daftar', halaman=pagination.prev_num, q=q) }}">← Previous</a>
  {% endif %}
  <span>{{ pagination.page }}/{{ pagination.pages }}</span>
  {% if pagination.has_next %}
    <a href="{{ url_for('produk.daftar', halaman=pagination.next_num, q=q) }}">Next →</a>
  {% endif %}
{% endif %}
{% endblock %}

Testing #

# tests/conftest.py

import pytest
from app        import create_app
from app.extensions import db as _db

@pytest.fixture(scope="session")
def app():
    app = create_app("testing")
    with app.app_context():
        _db.create_all()
        yield app
        _db.drop_all()

@pytest.fixture
def client(app):
    return app.test_client()

@pytest.fixture(autouse=True)
def db_session(app):
    """Roll back every transaction after the test finishes."""
    with app.app_context():
        connection = _db.engine.connect()
        transaction = connection.begin()
        _db.session.bind = connection

        yield _db.session

        _db.session.remove()
        transaction.rollback()
        connection.close()
# tests/test_api_produk.py

import json

def test_list_produk(client):
    response = client.get("/api/v1/produk")
    assert response.status_code == 200
    data = response.get_json()
    assert "data" in data
    assert "total" in data

def test_buat_produk(client):
    response = client.post(
        "/api/v1/produk",
        data=json.dumps({"nama": "Laptop Test", "harga": 15000000, "stok": 5}),
        content_type="application/json"
    )
    assert response.status_code == 201
    data = response.get_json()
    assert data["nama"]  == "Laptop Test"
    assert data["harga"] == 15000000.0
    assert "id" in data

def test_buat_produk_tanpa_nama(client):
    response = client.post(
        "/api/v1/produk",
        data=json.dumps({"harga": 10000}),
        content_type="application/json"
    )
    assert response.status_code == 422
    assert "nama" in response.get_json()["error"].lower()

def test_hapus_produk(client):
    # Create first
    create_resp = client.post(
        "/api/v1/produk",
        data=json.dumps({"nama": "Produk Hapus", "harga": 5000}),
        content_type="application/json"
    )
    produk_id = create_resp.get_json()["id"]

    # Delete
    delete_resp = client.delete(f"/api/v1/produk/{produk_id}")
    assert delete_resp.status_code == 204

    # Verify it's gone
    get_resp = client.get(f"/api/v1/produk/{produk_id}")
    assert get_resp.status_code == 404

When to Choose Flask vs Django vs FastAPI #

Choose Flask when:
  ✓ You need full control over the architecture without imposed conventions
  ✓ Building small microservices or simple REST APIs
  ✓ The team is already familiar with Flask and its ecosystem
  ✓ Rapid prototyping or applications with very specific needs

Choose Django when:
  ✓ Full-stack applications with many features (CMS, e-commerce, portals)
  ✓ The team wants proven conventions and structure
  ✓ You need a built-in admin interface, ORM, and authentication
  ✓ Long-term projects with many developers

Choose FastAPI when:
  ✓ Building modern APIs with automatic validation and OpenAPI documentation
  ✓ You need native async/await performance
  ✓ The team consistently uses type hints
  ✓ ML serving, microservices, or APIs with strict schemas

Summary #

  • Application Factory Pattern — create the Flask instance inside a create_app() function, not at the module level; this enables multiple configurations and clean testing.
  • Blueprints for modularity — separate features into distinct blueprints; each blueprint can have its own URL prefix, template folder, and error handlers.
  • request.get_json(silent=True) — use it so no exception is raised when the body isn’t valid JSON; check for None explicitly afterward.
  • Whitelist fields on update — don’t do Model(**request.json) directly; whitelist the allowed fields to prevent mass assignment vulnerabilities.
  • first_or_404() and get_or_404() — use them instead of .first() or .get() in routes that must return 404 when not found.
  • g for request context — store per-request data (timing, current user) in flask.g; it’s automatically cleaned up after each request.
  • before_request and after_request — use them for authentication, Content-Type validation, and centrally adding response headers.
  • to_dict() on models — define a serialization method on the model so JSON responses are consistent and you control which fields are included.
  • Class-based configuration — use separate DevelopmentConfig, TestingConfig, ProductionConfig; read sensitive values from environment variables.
  • Testing with rollback — use a fixture that rolls back the transaction after every test so the database is always clean without drop/create cycles.

← Previous: FastAPI   Next: PyTest →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact