🎉 Premium Proxies · 3-Day Free TrialClaim Now
Guides

How to Scrape Mercado Libre Product Data (API First)

Scrape Mercado Libre product data properly: start with the api.mercadolibre.com REST API, then fill the gaps by site ID, currency, shipping and language.

S SparkProxy 3 26 min read
Share
How to Scrape Mercado Libre Product Data (API First)

Before you write a scraper, check the API: Mercado Libre publishes a documented REST API at api.mercadolibre.com that already returns most of what people try to scrape Mercado Libre product data for, and page collection should only cover what the API withholds.

That single fact is missing from nearly every guide on this topic, and skipping it costs teams weeks. This article runs API first, then names precisely where the API stops and collection has to start: per-country site IDs and catalogue divergence, the 1,000 result ceiling on search, catalog products against seller listings, Mercado EnvĂ­os Full changing the effective price, and the Argentine inflation problem that makes an untimestamped ARS figure worthless.

The short answer: use the API first

Mercado Libre runs a public developer programme with documentation at developers.mercadolibre.com. The API base is https://api.mercadolibre.com, and the endpoints that matter for product research are ordinary REST:

EndpointReturnsNotes
`GET /sites`Every marketplace and its site IDAnonymous, tiny, cache it
`GET /sites/{site_id}`Default currency, currencies, categoriesAnonymous
`GET /sites/{site_id}/search?q=`Listing results with price and shippingToken required, offset capped
`GET /items/{item_id}`Full listing: price, condition, attributes, shippingToken required
`GET /items?ids=A,B,C`Up to 20 items in one callThe volume workhorse
`GET /items/{item_id}/description`Free-text descriptionSeparate call by design
`GET /categories/{category_id}`Category tree node and its filtersAnonymous
`GET /currencies/{currency_id}`Symbol and `decimal_places`Anonymous, load-bearing
`GET /products/{product_id}`Catalog product and its buy box winnerSite-scoped IDs

Start with the cheapest possible call, which needs no credentials at all:

curl -s "https://api.mercadolibre.com/sites" | head -c 400

You get the full site list back, and that response is the foundation of everything else. An engineer who builds on this API gets typed JSON, stable field names, and no anti-bot layer to fight. An engineer who opens with a headless browser gets brittle selectors and a permanent support burden, for data that was sitting behind a GET request the whole time.

Two honest caveats. Mercado Libre has progressively tightened the API: endpoints that were anonymous years ago now demand an OAuth token, and some fields have been deliberately removed from third-party responses. And the API is governed by the developer terms you accept when you register an application, which is a different agreement from the site's terms of use. Read both before building a commercial product on either path.

Site IDs, domains and diverging catalogues

Mercado Libre is not one marketplace. It is a family of national marketplaces sharing a platform, and each carries a site ID that prefixes every identifier you will touch.

Site IDCountryDomainCurrencyLanguage
MLAArgentinamercadolibre.com.arARSSpanish
MLBBrazilmercadolivre.com.brBRLPortuguese
MLMMexicomercadolibre.com.mxMXNSpanish
MLCChilemercadolibre.clCLPSpanish
MCOColombiamercadolibre.com.coCOPSpanish
MPEPerumercadolibre.com.pePENSpanish
MLUUruguaymercadolibre.com.uyUYUSpanish
MECEcuadormercadolibre.com.ecUSDSpanish
MCRCosta Ricamercadolibre.co.crCRCSpanish
MPAPanamamercadolibre.com.paUSDSpanish

Three details in that table break naive code.

Brazil spells it differently. The domain is mercadolivre.com.br, with a v. Hardcode the string "mercadolibre" into a URL builder and Brazil, the platform's largest market, silently drops out of your crawl. Chile breaks the pattern too: it is mercadolibre.cl, not .com.cl.

Item IDs carry the site prefix, and the web and API forms differ. A listing URL looks like https://articulo.mercadolibre.com.ar/MLA-1234567890-producto-_JM, while the API wants MLA1234567890. The hyphen is a display artefact. Strip it.

Dollarised markets need no conversion. Ecuador and Panama quote in USD. That is a free cross-country comparison baseline most analysts never notice they have.

Build the registry once and never string-concatenate a domain again:

SITES = {
    "MLA": {"country": "AR", "domain": "mercadolibre.com.ar", "currency": "ARS", "lang": "es"},
    "MLB": {"country": "BR", "domain": "mercadolivre.com.br", "currency": "BRL", "lang": "pt"},
    "MLM": {"country": "MX", "domain": "mercadolibre.com.mx", "currency": "MXN", "lang": "es"},
    "MLC": {"country": "CL", "domain": "mercadolibre.cl",     "currency": "CLP", "lang": "es"},
    "MCO": {"country": "CO", "domain": "mercadolibre.com.co", "currency": "COP", "lang": "es"},
    "MPE": {"country": "PE", "domain": "mercadolibre.com.pe", "currency": "PEN", "lang": "es"},
    "MEC": {"country": "EC", "domain": "mercadolibre.com.ec", "currency": "USD", "lang": "es"},
}

def api_item_id(url_id: str) -> str:
    """MLA-1234567890 (web form) -> MLA1234567890 (API form)."""
    return url_id.replace("-", "", 1)

Why the catalogues actually differ

The same brand and model can be a different listing, a different catalog product, and a different price tier in each country, because import duties, local distribution and category structures are national. Argentina's electronics catalogue reflects import conditions Brazil's does not. Category IDs are site-scoped as well (MLA1051 and MLB1051 are unrelated identifiers), so a category mapping built for Argentina is not portable to Brazil. Fetch GET /sites/{site_id}/categories per site and map by name plus attributes, never by numeric suffix.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Auth, rate limits and the 1,000 result ceiling

Most useful endpoints now need an OAuth 2.0 access token. Register an application in the developer console, then run the authorization code flow described in the authentication docs. The operational shape:

  • Access tokens are short lived, on the order of six hours. expires_in comes back in seconds, so read it instead of hardcoding a duration.
  • Refresh tokens are single use. Every refresh returns a new one, and if you lose it you re-authorize by hand.
  • Store the token where every worker can read it, and refresh once, centrally. Twenty workers each refreshing independently will invalidate each other and produce a confusing cascade of 401s.
import time, requests

TOKEN_URL = "https://api.mercadolibre.com/oauth/token"

def refresh(client_id: str, client_secret: str, refresh_token: str) -> dict:
    r = requests.post(TOKEN_URL, data={
        "grant_type": "refresh_token",
        "client_id": client_id,
        "client_secret": client_secret,
        "refresh_token": refresh_token,
    }, timeout=30)
    r.raise_for_status()
    tok = r.json()
    # Persist BOTH values. The refresh token rotates on every single call.
    tok["expires_at"] = time.time() + tok["expires_in"] - 300
    return tok

Then the multiget, the endpoint that makes API-first economical. One call returns up to 20 items, and attributes trims the payload to the fields you actually store:

FIELDS = ("id,title,price,original_price,currency_id,condition,"
          "available_quantity,permalink,category_id,catalog_product_id,"
          "catalog_listing,listing_type_id,shipping,attributes,seller_id")

def get_items(ids: list[str], token: str) -> list[dict]:
    out = []
    for i in range(0, len(ids), 20):                     # multiget caps at 20
        r = requests.get("https://api.mercadolibre.com/items",
                         params={"ids": ",".join(ids[i:i + 20]), "attributes": FIELDS},
                         headers={"Authorization": f"Bearer {token}"}, timeout=30)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", 5)))
            continue
        for row in r.json():
            if row.get("code") == 200:                   # multiget wraps each result
                out.append(row["body"])
    return out

Look closely at that response shape. The multiget returns a list of envelopes, each with its own code and body. A deleted or restricted item comes back as a 404 envelope inside an HTTP 200 response, so a naive r.json()[0]["price"] raises KeyError on a perfectly successful call.

The ceiling nobody warns you about

Search paginates with offset and limit, and offset + limit cannot exceed 1,000. A category holding 80,000 active listings will hand you exactly 1,000 of them in relevance order, then stop. There is no page 51.

The fix is to slice the query into buckets that each return under 1,000 results, using filters the API already exposes: category, condition, listing type and above all price bands.

def price_slices(site: str, category: str, token: str, edges: list[int]):
    """Walk price bands so no single query hits the 1,000 offset ceiling."""
    for lo, hi in zip(edges, edges[1:]):
        offset = 0
        while offset < 1000:
            r = requests.get(f"https://api.mercadolibre.com/sites/{site}/search",
                             params={"category": category, "price": f"{lo}-{hi}",
                                     "offset": offset, "limit": 50},
                             headers={"Authorization": f"Bearer {token}"}, timeout=30)
            body = r.json()
            results = body.get("results", [])
            if not results:
                break
            yield from results
            total = body.get("paging", {}).get("total", 0)
            if total > 1000:
                print(f"WARN band {lo}-{hi} holds {total} results, split it further")
            offset += 50

That WARN line matters more than the loop around it. If a band reports over 1,000 total, you are silently missing listings, and without the warning nobody discovers that a "complete" category sweep covered a third of the category. Pair this with sane pacing; our guide on ethical scraping and rate limiting covers the backoff patterns that keep an application in good standing.

What the API will not give you

Here is where collection earns its place. The API is generous, but several things a pricing or merchandising team needs live only on the rendered page.

You needAPI statusWhere it lives
Sold-quantity signalWithheld or zeroed for third partiesPage text buckets, "+100 vendidos"
Exact stockRounded into buckets above a thresholdPage shows a rounded figure too
Installment offer ("12x sin interés")Partial, not the rendered offerPrice block on the item page
Search ranking and ad slotsNot exposedRendered search results order
Promotional badges and countdownsNot exposedItem and search page markup
Review text and reviewer detailRestrictedPage, and out of scope on privacy grounds
Buy box presentation`buy_box_winner` onlyProduct page layout

Interest-free installments deserve emphasis. Across Latin America a listing at 12x sin interés competes on a different axis from the same price paid outright, and in appliances and phones the installment plan is closer to the real purchase driver than the sticker price. It is a rendered-page attribute. A competitive model that ignores it will be wrong about why a more expensive listing outsells a cheaper one.

Sold quantity is the other one people trip on. Mercado Libre stopped exposing exact sales counts to third parties, so the item response either omits the field or returns a value you cannot trust, while the web page still shows a bucketed string. Treat that bucket as an ordinal signal ("more than 100"), never as a number, and verify current behaviour against a listing you control before you build on it.

Catalog products versus seller listings

Mercado Libre runs two overlapping object models, and confusing them produces a price dataset that double counts.

A seller listing (an item, MLA1234567890) is one seller's offer. A catalog product (MLA12345678, reached at /p/{product_id}) is the normalized product page where many sellers compete for a single buy box, much like an Amazon ASIN. An item competing in the catalog carries catalog_listing: true and a catalog_product_id.

def classify(item: dict) -> str:
    if item.get("catalog_listing") and item.get("catalog_product_id"):
        return "catalog_competitor"     # one of many offers on a /p/ page
    if item.get("catalog_product_id"):
        return "catalog_linked"         # linked but not competing for the box
    return "standalone"                 # its own listing, no catalog page

Two rules follow. If you are tracking market prices, aggregate at the catalog_product_id level and keep item-level rows underneath it, otherwise fifteen sellers offering the same phone become fifteen separate "products". And here is the trap: catalog product IDs are site-scoped. MLA catalog IDs do not correspond to MLB ones. There is no cross-border product key anywhere in the platform, which is why the matching section below exists at all.

The buy box winner deserves its own capture. GET /products/{product_id} returns a buy_box_winner object, and tracking which item holds it over time says more about competitive dynamics than price alone. The same idea drives Amazon buy box tracking, covered in How to Scrape Amazon Product Data, applied to a marketplace with a very different seller base.

Fill the gaps with the SparkProxy Scraping API

For the rendered-page fields you need pages fetched from the right country with the anti-bot layer handled. The SparkProxy Scraping API takes a target URL and returns the HTML. Base endpoint https://scrape.sparkproxy.io/api/v1, auth via a single X-API-Key header. Four parameters carry the work here:

  • country_code: the ISO alpha-2 exit country. Not optional on Mercado Libre. An Argentine listing fetched from a US IP can resolve differently, and shipping estimates are computed from the detected location.
  • render_js: the item page assembles its price block client side, so render for item and search pages.
  • premium_proxy: residential exits, which survive far longer than datacenter ranges on a marketplace this size.
  • stealth: fingerprint hardening for the pages that challenge.
curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "url=https://articulo.mercadolibre.com.ar/MLA-1234567890-producto-_JM" \
  --data-urlencode "render_js=true" \
  --data-urlencode "premium_proxy=true" \
  --data-urlencode "country_code=AR"

Wrap it so the site ID picks the exit country automatically, which removes an entire class of quiet data corruption:

SCRAPE_API = "https://scrape.sparkproxy.io/api/v1"

def fetch_page(site_id: str, url: str, render: bool = True) -> str:
    resp = requests.get(SCRAPE_API,
        headers={"X-API-Key": "YOUR_API_KEY"},
        params={
            "url": url,
            "render_js": "true" if render else "false",
            "premium_proxy": "true",
            "stealth": "true",
            "country_code": SITES[site_id]["country"],   # MLB -> BR, MLA -> AR
        }, timeout=120)
    resp.raise_for_status()
    return resp.text

The full parameter list is in the Scraping API docs. If it is not obvious why the exit country changes the payload at all, what geo targeting means in proxies explains the mechanics.

Parse an item page without chasing class names

Mercado Libre's front end uses the Andes design system, so you will meet classes like ui-pdp-title and andes-money-amount__fraction. They work today. They also get redesigned on the platform's schedule rather than yours, so treat them as a fallback layer, not a foundation.

The durable anchor is the item ID in the URL. Every listing link on a search page contains one, which lets you harvest IDs structurally and hand them to the API multiget, where field names are contractual:

import re

ITEM_ID = re.compile(r"/(MLA|MLB|MLM|MLC|MCO|MPE|MLU|MEC)-?(\d{8,})")

def item_ids_from_html(html: str) -> list[str]:
    """Harvest listing IDs from any ML page. Immune to CSS churn."""
    seen, out = set(), []
    for site, num in ITEM_ID.findall(html):
        iid = f"{site}{num}"
        if iid not in seen:
            seen.add(iid)
            out.append(iid)
    return out

That one function is the whole bridge between the two halves of the pipeline: pages give you IDs, the API gives you fields. For the handful of fields that exist only on the page, parse narrowly and defensively:

from bs4 import BeautifulSoup

def page_only_fields(html: str) -> dict:
    soup = BeautifulSoup(html, "html.parser")

    def text(selector: str) -> str | None:
        node = soup.select_one(selector)
        return node.get_text(" ", strip=True) if node else None

    return {
        "title_page": text("h1.ui-pdp-title"),
        "sold_bucket": sold_bucket(text(".ui-pdp-subtitle")),   # "Nuevo | +100 vendidos"
        "installments": text(".ui-pdp-price__subtitles"),       # "en 12x $... sin interés"
        "free_shipping_badge": ("Envío gratis" in html) or ("Frete grátis" in html),
    }

def sold_bucket(subtitle: str | None) -> int | None:
    """'+100 vendidos' or '+100 vendidos' in pt -> 100. An ordinal floor, not a count."""
    if not subtitle:
        return None
    m = re.search(r"\+?\s*([\d.]+)\s*(vendidos|vendidas|vendas)", subtitle)
    return int(m.group(1).replace(".", "")) if m else None

Notice the Portuguese fallback in the free-shipping check. A large share of the platform's traffic reads Frete grátis, and a Spanish-only string match reports zero free shipping across the whole of Brazil. Where a marketplace ships its payload as an embedded JSON blob instead of markup, as AliExpress does with window.runParams in how to scrape AliExpress product data, that route beats DOM parsing every time. Mercado Libre gives you the documented API instead, which is a better deal, but run the DevTools pass for hidden JSON endpoints before writing any selector.

Currency, inflation and honest price capture

This is the section that separates a Mercado Libre dataset you can use from one you cannot, and almost nobody writes about it.

Argentina's national statistics institute, INDEC, recorded annual CPI inflation of 211.4% in 2023 and 117.8% in 2024. At those rates an ARS price recorded on the 3rd and an ARS price recorded on the 28th are not the same unit of measurement. A row reading price: 450000, currency: ARS with no capture timestamp is not a data point. It is a number.

Worse, there is no single ARS-to-USD rate. The official rate, the MEP rate and the CCL rate have diverged for years, at times by more than double. Convert with the wrong series and a comparison against Brazil is off by a factor that has nothing to do with retail pricing.

The rule that fixes it: store the local-currency amount, the currency code, a UTC capture timestamp, and if you convert at all, store the rate and its source alongside the result rather than replacing the original.

from datetime import datetime, timezone

def price_record(item: dict, fx: dict | None = None) -> dict:
    rec = {
        "item_id": item["id"],
        "amount_local": item["price"],          # NEVER overwrite this
        "currency_id": item["currency_id"],     # ARS, BRL, MXN, CLP, COP...
        "captured_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }
    if fx:
        rec |= {
            "usd_amount": round(item["price"] / fx["rate"], 4),
            "fx_rate": fx["rate"],
            "fx_series": fx["series"],          # "official" | "mep" | "ccl"
            "fx_source": fx["source"],
            "fx_asof": fx["asof"],
        }
    return rec

Decimal places are the second trap. Chilean pesos are conventionally quoted with zero decimals while Argentine and Brazilian amounts use two. Do not guess, and do not use floats for money in a table you plan to aggregate. The API answers the question directly:

from decimal import Decimal
import functools

@functools.lru_cache(maxsize=32)
def decimal_places(currency_id: str) -> int:
    r = requests.get(f"https://api.mercadolibre.com/currencies/{currency_id}", timeout=15)
    return r.json().get("decimal_places", 2)

def to_decimal(amount, currency_id: str) -> Decimal:
    dp = decimal_places(currency_id)
    return Decimal(str(amount)).quantize(Decimal(1).scaleb(-dp))

Capture original_price alongside price while you are at it. Mercado Libre runs heavy campaign calendars, Hot Sale in Argentina and Mexico, Black Friday in Brazil, and a discount depth series built from original_price minus price carries more information than either field alone. The broader methodology for building comparable price series across retailers is in how to scrape ecommerce prices.

Mercado EnvĂ­os Full and the effective price

A listing's price is not what the buyer pays. Shipping is, in several categories, the difference between the winning offer and the third-place one, and Mercado Libre encodes it in the item's shipping object.

FieldValuesWhat it tells you
`shipping.free_shipping``true` / `false`Seller absorbs the freight
`shipping.mode``me2`, `me1`, `custom`, `not_specified``me2` is managed Mercado EnvĂ­os
`shipping.logistic_type``fulfillment`, `cross_docking`, `drop_off`, `xd_drop_off`, `self_service``fulfillment` is Full
`shipping.store_pick_up``true` / `false`Collection option

logistic_type: "fulfillment" is Mercado EnvĂ­os Full, meaning the stock already sits in a Mercado Libre warehouse. Those listings ship faster, are favoured in ranking, and often qualify for free shipping at lower thresholds. A Full listing at a slightly higher price routinely beats a cheaper drop-off listing, so a competitive model that compares sticker prices across different logistic_type values is comparing different products.

def effective_price(item: dict, shipping_cost: float = 0.0) -> dict:
    ship = item.get("shipping") or {}
    free = bool(ship.get("free_shipping"))
    return {
        "list_price": item["price"],
        "shipping_cost": 0.0 if free else shipping_cost,
        "effective_price": item["price"] + (0.0 if free else shipping_cost),
        "logistic_type": ship.get("logistic_type"),
        "is_full": ship.get("logistic_type") == "fulfillment",
        "comparable_group": ship.get("logistic_type") or "unknown",
    }

When shipping is not free its cost depends on the destination postcode, so it is a property of the buyer rather than of the listing. Query GET /items/{item_id}/shipping_options?zip_code= against a fixed set of representative postcodes per country (a capital, a large secondary city, a remote region) and store one row per postcode. Choose those postcodes once, write them down, and never change them silently, because a shifted reference postcode looks exactly like a price change on a dashboard.

Matching Portuguese and Spanish titles across countries

Cross-country comparison is why most teams collect Mercado Libre data at all, and it is where the platform helps least. Catalog product IDs are site-scoped. Category IDs are site-scoped. Titles are seller-written, and Brazil writes them in Portuguese.

Match in this order.

1. GTIN, when present. The item attributes array carries structured pairs, and GTIN (or EAN) is a global identifier. It is the only genuinely reliable cross-border key.

def attr(item: dict, attr_id: str) -> str | None:
    for a in item.get("attributes", []):
        if a.get("id") == attr_id:
            return a.get("value_name")
    return None

def match_key(item: dict) -> tuple[str, str]:
    gtin = attr(item, "GTIN") or attr(item, "EAN")
    if gtin:
        return ("gtin", gtin.strip())
    brand, model = attr(item, "BRAND"), attr(item, "MODEL")
    if brand and model:
        return ("brand_model", f"{brand.lower().strip()}|{model.lower().strip()}")
    return ("title", "")     # fall through to fuzzy matching

2. Brand plus model attributes. Structured, chosen by sellers from controlled lists in most categories, and language independent. Far better than the title.

3. Normalized titles, last. Strip accents, lowercase, drop marketing noise, and translate the handful of category nouns that genuinely differ between the two languages:

import unicodedata

PT_ES = {
    "geladeira": "heladera", "fogao": "cocina", "tenis": "zapatillas",
    "relogio": "reloj", "fone": "auricular", "computador": "computadora",
    "televisao": "televisor", "maquina de lavar": "lavarropas",
    "brinquedo": "juguete", "mochila": "mochila",
}
NOISE = re.compile(r"\b(novo|nuevo|original|envio gratis|frete gratis|oferta|promocao|promocion)\b")

def normalize_title(title: str, lang: str) -> str:
    t = unicodedata.normalize("NFKD", title.lower())
    t = "".join(c for c in t if not unicodedata.combining(c))   # accents out
    t = NOISE.sub(" ", t)
    if lang == "pt":
        for pt, es in PT_ES.items():
            t = t.replace(pt, es)
    return " ".join(t.split())

Accent stripping does real work here. televisĂŁo, televisao and televisiĂłn collapse to one token only after NFKD normalization, and a raw comparison of the accented forms fails on all three. Score the normalized titles with a token-set ratio and hold a deliberately high threshold. A false match across countries is worse than a gap, because it manufactures a confident price differential between two products that are not the same product. Keep the match method in the row so an analysis can be filtered down to GTIN-matched pairs when the answer has to be defensible.

A schema that keeps personal data out

Latin American data protection law is real and enforced. Brazil's LGPD (Lei 13.709/2018) is supervised by the ANPD, Argentina operates under Ley 25.326, and Mexico replaced its 2010 federal statute with a new framework published in March 2025. All of them protect information about an identifiable natural person, and none of them carve out an exception because the information appeared on a public web page.

On Mercado Libre a very large share of sellers are individuals. A nickname, a reputation history and a location together identify a person. Reviewer names and review text plainly do. Product titles, prices, shipping configuration and aggregate ratings do not.

Design the boundary into the table rather than into a policy document, because a column that does not exist cannot be quietly filled in by a hurried analyst six months from now:

CREATE TABLE meli_listing_price (
  site_id            CHAR(3)        NOT NULL,     -- MLA, MLB, MLM...
  item_id            VARCHAR(20)    NOT NULL,     -- MLA1234567890
  catalog_product_id VARCHAR(20),                 -- site-scoped, never cross-site
  captured_at        TIMESTAMPTZ    NOT NULL,     -- mandatory, see the currency section
  title              TEXT           NOT NULL,
  condition          VARCHAR(12),                 -- new | used | not_specified
  amount_local       NUMERIC(14,2)  NOT NULL,
  original_price     NUMERIC(14,2),
  currency_id        CHAR(3)        NOT NULL,
  fx_rate            NUMERIC(18,6),               -- NULL if not converted
  fx_series          VARCHAR(12),                 -- official | mep | ccl
  free_shipping      BOOLEAN,
  logistic_type      VARCHAR(16),                 -- fulfillment = Full
  effective_price    NUMERIC(14,2),
  installments       SMALLINT,
  sold_bucket        INTEGER,                     -- ordinal floor, not a count
  rating_average     NUMERIC(3,2),                -- aggregate only
  rating_count       INTEGER,                     -- aggregate only
  gtin               VARCHAR(20),
  brand              VARCHAR(80),
  model              VARCHAR(120),
  seller_id          BIGINT,                      -- pseudonymous key, no profile
  PRIMARY KEY (item_id, captured_at)
);
-- Deliberately absent: seller_nickname, seller_address, seller_reputation,
-- reviewer_name, reviewer_id, review_text, buyer questions. Not collected,
-- so there is nothing to leak, nothing to hand over on a subject access
-- request, and nothing to delete.

seller_id survives because deduplication and "how many distinct sellers hold this catalog page" are legitimate market questions needing a stable key. It is an opaque integer with no profile attached, and nothing in the pipeline joins it to a name. If your analysis does not need it, drop that column too. Retention rules and access control belong on the same table definition, written down next to it.

One more design note. A primary key on (item_id, captured_at) makes the table append only. You never update a price row, you insert a new one. That is what makes the inflation problem tractable, because every figure in the table stays anchored to the moment it was true.

Frequently asked questions

FAQ

Yes. Mercado Libre publishes a documented REST API at api.mercadolibre.com covering sites, categories, search, items, catalog products and currencies. A few endpoints are anonymous, but most now require an OAuth 2.0 access token obtained by registering an application in the developer console.

The main ones are MLA (Argentina), MLB (Brazil), MLM (Mexico), MLC (Chile), MCO (Colombia), MPE (Peru), MLU (Uruguay), MEC (Ecuador), MCR (Costa Rica) and MPA (Panama). The site ID prefixes every item and category identifier, so MLA1234567890 and MLB1234567890 belong to different marketplaces and are not comparable as keys.

Search paginates with offset and limit, and their sum is capped at 1,000 no matter how many listings match. Slice each query into narrower buckets by category, condition and price band so every bucket reports fewer than 1,000 total results, and raise an alert whenever a bucket exceeds it.

Store the ARS amount, the currency code and a UTC capture timestamp on every row, and never overwrite the local amount with a converted one. INDEC recorded 211.4% inflation in 2023 and 117.8% in 2024, and the official, MEP and CCL exchange rates diverge widely, so a converted figure without its rate and series is not reproducible.

A seller listing is one seller's individual offer, while a catalog product is the normalized page where many sellers compete for a single buy box. Items competing in the catalog carry catalog_listing: true and a catalog_product_id, and aggregating at the catalog level stops one product being counted fifteen times.

Treat both as personal data. Many Mercado Libre sellers are individuals, and reviewer names and review text identify people under Brazil's LGPD and Argentina's Ley 25.326, so keep aggregate ratings and rating counts only and design the schema without reviewer identity or seller profile columns.

Special Discount · 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates the infrastructure behind SparkProxy's datacenter proxies, residential proxies and Scraping API: geo targeted exit routing, session and cookie control, browser rendering at scale, and the anti bot behaviour of high traffic commerce platforms. The guidance here comes from production collection workloads across Latin American and global marketplaces, not theory. Questions are welcome at support@sparkproxy.io.

Keep reading

Related articles

How to Scrape Baidu Search Results Accurately

How to Scrape Baidu Search Results Accurately

How to scrape Baidu search results: the pn parameter, GBK encoding traps, resolving baidu.com/link redirects, and parsing Baijiahao and Zhidao blocks.

SparkProxy·Guides