API Filtering: retrieving data like a coffee connoisseur

API Filtering: retrieving data like a coffee connoisseur

Table of Contents

In my first year of working, I once wrote an endpoint GET /api/menus that returned… the entire menu. 200 items every time it was called. The JSON was 1.2MB heavy. The frontend only needed the name and price of 10 active dishes. I remember the first thing my lead said: “You’re sending the entire warehouse to someone who just needs to view the menu, aren’t you?”

That lesson took a week to refactor. Since then, every time I design an API, I think of a coffee shop: when a customer orders an iced Americano, the barista doesn’t bring out the entire counter - they take the order clearly, make the right drink, and serve the right cup. APIs should work the same way.

Here are 3 patterns I use to make APIs “order” instead of “bring the entire counter.” All the code is in Go, runs smoothly, and can be copy-pasted into your project for immediate use.

API Filtering

1. Only show items that are on sale - filter query params

The most basic filter: the client only wants active items.

GET /api/products?status=active

Simple Go backend parsing:

func parseFilters(q url.Values) []Filter {
    var filters []Filter
    for key, values := range q {
        switch {
        case strings.HasSuffix(key, "_gte"):
            field := strings.TrimSuffix(key, "_gte")
            filters = append(filters, Filter{field, ">=", parseFloat(values[0])})
        case strings.HasSuffix(key, "_lte"):
            field := strings.TrimSuffix(key, "_lte")
            filters = append(filters, Filter{field, "<=", parseFloat(values[0])})
        case strings.HasSuffix(key, "_in"):
            field := strings.TrimSuffix(key, "_in")
            filters = append(filters, Filter{field, "IN", strings.Split(values[0], ",")})
        case strings.HasSuffix(key, "_like"):
            field := strings.TrimSuffix(key, "_like")
            filters = append(filters, Filter{field, "LIKE", "%" + values[0] + "%"})
        default:
            // exact match
            filters = append(filters, Filter{key, "=", values[0]})
        }
    }
    return filters
}

Pitfall I’ve encountered: using _like on a column without a full-text index. 100K records × LIKE ‘%keyword%’ = sequential scan. If you need fuzzy search, push it to Elasticsearch or at least use PostgreSQL pg_trgm.

Some operators that should be supported:

OperatorExampleWhen to use
_eq (or default)status=activeExact filtering
_necategory_ne=archivedExclusion
_gte / _lteprice_gte=50000Price range
_instatus_in=active,pendingMultiple values
_likename_like=coffeeText search (be careful with indexing!)

2. Sorting New Items to the Top

GET /api/posts?sort=created_at&order=desc

Go Code:

func applySort(db *gorm.DB, q url.Values) *gorm.DB {
    sortField := q.Get("sort")
    order := q.Get("order")

    if sortField == "" {
        return db.Order("created_at DESC") // default
    }

    // Whitelist fields - don't let the client sort arbitrary columns!
    allowed := map[string]bool{
        "created_at": true, "price": true, "name": true, "updated_at": true,
    }
    if !allowed[sortField] {
        return db // ignore invalid field
    }

    if order == "asc" {
        return db.Order(sortField + " ASC")
    }
    return db.Order(sortField + " DESC")
}

I once made a mistake: allowing ?sort=anything without whitelisting. If the client enters ?sort=1;DROP TABLE products;--, it won’t cause any issues because GORM escapes the input, but ?sort=json_data will result in a 300ms query because the column is not indexed. Whitelisting and indexing are mandatory, not optional.

-- Index common sort columns to avoid full scans
CREATE INDEX idx_products_created_at ON products(created_at DESC);
CREATE INDEX idx_products_price ON products(price ASC);

3. I only need the name and price - field selection

GET /api/products?fields=name,price,thumbnail

This is the most overlooked optimization. A JSON response with 20 fields and nested objects is much heavier than 3 flat fields.

func applyFieldSelection(db *gorm.DB, q url.Values) *gorm.DB {
    fields := q.Get("fields")
    if fields == "" {
        return db // return all columns
    }

    selected := strings.Split(fields, ",")
    allowed := map[string]bool{
        "id": true, "name": true, "price": true,
        "thumbnail": true, "status": true, "created_at": true,
    }

    var columns []string
    for _, f := range selected {
        if allowed[strings.TrimSpace(f)] {
            columns = append(columns, strings.TrimSpace(f))
        }
    }

    if len(columns) == 0 {
        return db // fallback to all if invalid
    }
    return db.Select(columns)
}

In my food delivery project, I reduced the payload size by 40% just by adding field selection for the mobile client. On 3G, 40% is significant.

Important Order: Filter → Sort → Paginate

When combining all three:

GET /api/products?category=tea&price_gte=30000&sort=price&order=asc&page=1&limit=20

Backend flow:

func ListProducts(ctx context.Context, q url.Values) ([Product], int64, error) {
    db := db.WithContext(ctx).Model(&Product{})

    db = applyFilters(db, q)      // 1. Filter
    db = applySort(db, q)         // 2. Sort
    // 3. Paginate (AFTER filter+sort)
    var total int64
    db.Count(&total)

    var products [Product]
    offset, limit := parsePagination(q)
    db.Offset(offset).Limit(limit).Find(&products)

    return products, total, nil
}

Don’t paginate before filtering - you’ll be missing data and the total count will be incorrect. I’ve seen this error in 2/3 of junior codebases during review.

Bonus: Test your filter immediately

func TestListProducts_FilterByStatus(t *testing.T) {
    // seed 3 active + 2 inactive
    seedProducts(t, 3, "active")
    seedProducts(t, 2, "inactive")

    req, _ := http.NewRequest("GET", "/api/products?status=active", nil)
    products, _, err := handler.ListProducts(req.Context(), req.URL.Query())

    require.NoError(t, err)
    assert.Len(t, products, 3)
}

Bottom line

A good API is not the one that returns the most data - but the one that provides the client with enough and correct data. Filtering, sorting, and field selection take 30 minutes to implement but save hours of debugging, reduce payload, and decrease database load. Everyone knows this, but few people do all three.

Have you ever encountered an endpoint that returns a 1MB JSON while the frontend only needs 3 fields? Or are you still maintaining one? Share your story with me - lessons from “fat APIs” are always the most valuable. ☕

Share :

Related Posts

Context in Go: passing data, deadline, cancel - use correctly or die of performance

Context in Go: passing data, deadline, cancel - use correctly or die of performance

When I first coded in Go, I encountered a strange production bug: after running for 3-4 days, the service suddenly consumed 8GB of RAM and caused an OOM error. I spent the entire Friday afternoon debugging and finally discovered the issue: a goroutine never ended because I forgot to pass the context to the gRPC call. Each request leaked a goroutine, and after 100K requests, the server was overwhelmed.

Read More