# How hard the land is stocked

**Answer.** Uruguay carries **0.856 cattle per hectare** of livestock land (12,265,269 cattle on 14.32 million ha). The densest department, **San José**, carries 1.67; the sparsest of the big grazing departments, Artigas, 0.65.

Notebook: https://cow-tools.sophia-labs.com/notebooks/stocking-intensity/stocking-intensity.ipynb · Page: https://cow-tools.sophia-labs.com/notebooks/stocking-intensity/index.html · Data: Uruguay MGAP/SNIG DICOSE 2024–2025 (graph `uruguay-cows` at https://cow-tools.sophia-labs.com/mcp).

## Findings

- Uruguay carries **0.856 cattle per hectare** of livestock land (12,265,269 cattle on 14.32 million ha). The densest department, **San José**, carries 1.67; the sparsest of the big grazing departments, Artigas, 0.65.
- Density follows the sown pasture: across departments, Spearman ρ = **0.847** between cattle per hectare and the share of land under *praderas*. Nationally 72.4% of declared land is native grassland and only 6.9% sown pasture: intensity is a thin southern veneer.
- Of 629 census areas with at least 100 livestock hectares, the densest is area **317003 in Canelones** at 8.33 cattle per hectare; the median area carries 0.91.

## The notebook

Most of Uruguay's cattle graze *campo natural*, native grassland that has
fed cattle since the 17th century. Where farmers have sown pastures
(*praderas*) or improved the native grass, the land carries more animals.
This notebook measures stocking density, cattle per hectare of livestock
land, at two scales: the 19 departments and the 600-odd *áreas de
enumeración*, DIEA's census areas, the finest grain DICOSE publishes.

Two inputs, both from the declarations: cattle (beef + dairy, PD_AD) from
`datosanimales.csv`, and *superficie ganadera*, the hectares a holder uses for
livestock, from `datosgenerales.csv`.

```python
# Setup.
import altair as alt
import pandas as pd

import cowtools_client as cw
import cowtools_charts as cc

names = cc.department_names()
```

```python
# The query: cattle and livestock hectares per census area, 2025.
cattle = cw.sparql("""
SELECT ?department ?area (SUM(?n) AS ?cattle)
WHERE {
  GRAPH ?g {
    ?obs a cow:AnimalFlowObservation ; cow:year "2025"^^xsd:gYear ;
         cow:department ?department ; cow:enumerationArea ?area ;
         cow:species ?s ; cow:animalsCount ?n .
    VALUES ?s { <urn:sophia:cow:species/1> <urn:sophia:cow:species/11> }
  }
}
GROUP BY ?department ?area
""")
land = cw.sparql("""
SELECT ?department ?area (SUM(?ha) AS ?livestock_ha) (SUM(?lu) AS ?livestock_units)
WHERE {
  GRAPH ?g {
    ?obs a cow:GeneralHoldingObservation ; cow:year "2025"^^xsd:gYear ;
         cow:department ?department ; cow:enumerationArea ?area ;
         cow:livestockHectares ?ha ; cow:livestockUnits ?lu .
  }
}
GROUP BY ?department ?area
""")
ae = cattle.merge(land, on=["department", "area"], how="outer")
ae["code"] = ae["department"].map(cw.dept_code)
ae["area_code"] = ae["area"].map(cw.dept_code)
ae["cattle"] = ae["cattle"].fillna(0).astype(int)
for c in ("livestock_ha", "livestock_units"):
    ae[c] = ae[c].apply(lambda v: float(v) if v is not None and v == v else 0.0)
print(f"{ae['area_code'].nunique()} census-area codes, {len(ae)} (department, area) cells")
```

```text
635 census-area codes, 667 (department, area) cells
```

A census area can straddle a department line (the area follows the land; the
department follows where the holder is registered), so we key on the pair.
Area code `0` collects holdings DICOSE could not place in any area.

```python
dept = ae.groupby("code")[["cattle", "livestock_ha", "livestock_units"]].sum().reset_index()
dept["name"] = dept["code"].map(names)
dept["density"] = dept["cattle"] / dept["livestock_ha"]
dept = dept.sort_values("density", ascending=False)
national = dept["cattle"].sum() / dept["livestock_ha"].sum()
cc.show_table(dept[["name", "density", "cattle", "livestock_ha"]], floatfmt=",.3f", name="density-by-department")
```

| name           |   density | cattle    | livestock_ha   |
|:---------------|----------:|:----------|:---------------|
| San José       |     1.67  | 541,254   | 324,103.300    |
| Colonia        |     1.553 | 537,066   | 345,775.100    |
| Canelones      |     1.391 | 338,601   | 243,465.000    |
| Florida        |     1.186 | 1,117,097 | 942,194.800    |
| Soriano        |     1.15  | 567,452   | 493,507.300    |
| Flores         |     1.005 | 409,429   | 407,511.500    |
| Durazno        |     0.97  | 952,171   | 981,607.200    |
| Montevideo     |     0.918 | 2,736     | 2,980.200      |
| Río Negro      |     0.875 | 549,369   | 627,763.500    |
| Rivera         |     0.83  | 639,917   | 770,887.200    |
| Cerro Largo    |     0.782 | 950,412   | 1,214,639.600  |
| Lavalleja      |     0.766 | 654,451   | 853,971.200    |
| Rocha          |     0.755 | 682,644   | 903,944.000    |
| Tacuarembó     |     0.751 | 1,061,136 | 1,413,868.000  |
| Treinta y Tres |     0.725 | 584,734   | 806,167.600    |
| Salto          |     0.679 | 898,899   | 1,323,035.000  |
| Paysandú       |     0.679 | 801,512   | 1,180,985.200  |
| Maldonado      |     0.667 | 234,191   | 351,185.500    |
| Artigas        |     0.655 | 742,198   | 1,133,714.600  |

### Cross-check against the source

```python
g = cw.source_csv(2025, "datosgenerales.csv")
a = cw.source_csv(2025, "datosanimales.csv")
src_ha = cw.num(g["SuperficieGanadera"]).groupby(g["DepartamentoCodigo"]).sum()
bov = a[a["EspecieCodigo"].isin(["1", "11"])]
src_cattle = cw.num(bov["AnimalesPorEspeciePD_AD"]).groupby(bov["DepartamentoCodigo"]).sum()
d = dept.set_index("code")
cw.check("livestock hectares per department: cube = CSV (largest difference, ha)",
         round(float((d["livestock_ha"] - src_ha.reindex(d.index)).abs().max()), 6), 0.0,
         source="datosgenerales.csv 2025, SuperficieGanadera")
cw.check("cattle per department: cube = CSV (largest difference, heads)",
         int((d["cattle"] - src_cattle.reindex(d.index)).abs().max()), 0,
         source="datosanimales.csv 2025")
cw.check("national livestock hectares, millions", round(dept["livestock_ha"].sum() / 1e6, 2), 14.32, tol=0.005,
         source="expedition 2026-07-05: 14.32M ha")
cw.check("national density, cattle per livestock ha", round(national, 3), 0.856, tol=0.0005,
         source="expedition 2026-07-05: 0.856")
cw.check("San José density", round(d.loc["16", "density"], 2), 1.67, tol=0.005, source="expedition 2026-07-05")

# The cube also materialises this ratio at ingest (cow:metric_bovine_per_livestock_hectare, computed by cow-tools
# from its own sums): all 19 departments must agree with ours.
derived = cw.sparql("""
SELECT ?department ?value
WHERE { GRAPH ?g { ?o a cow:DerivedMetricObservation ; cow:year "2025"^^xsd:gYear ;
                     cow:metric cow:metric_bovine_per_livestock_hectare ;
                     cow:department ?department ; cow:metricValue ?value } }
""")
derived["code"] = derived["department"].map(cw.dept_code)
gap = (d["density"] - derived.set_index("code")["value"].astype(float).reindex(d.index)).abs().max()
cw.check("all 19 densities = the cube's derived metric (largest gap)", round(float(gap), 6), 0.0, tol=1e-6,
         source="cow:metric_bovine_per_livestock_hectare, materialised at ingest")
cw.check("Artigas density", round(d.loc["1", "density"], 4), 0.6547, tol=0.00005,
         source="the July expedition printed 0.66; the exact value is 0.6547, so that was a rounding slip")
```

```text
PASS  livestock hectares per department: cube = CSV (largest difference, ha): 0 vs 0 (exactly)  — datosgenerales.csv 2025, SuperficieGanadera
PASS  cattle per department: cube = CSV (largest difference, heads): 0 vs 0 (exactly)  — datosanimales.csv 2025
PASS  national livestock hectares, millions: 14.32 vs 14.32 (within ±0.005)  — expedition 2026-07-05: 14.32M ha
PASS  national density, cattle per livestock ha: 0.856 vs 0.856 (within ±0.0005)  — expedition 2026-07-05: 0.856
PASS  San José density: 1.67 vs 1.67 (within ±0.005)  — expedition 2026-07-05
```

```text
PASS  all 19 densities = the cube's derived metric (largest gap): 0 vs 0 (within ±1e-06)  — cow:metric_bovine_per_livestock_hectare, materialised at ingest
PASS  Artigas density: 0.6547 vs 0.6547 (within ±5e-05)  — the July expedition printed 0.66; the exact value is 0.6547, so that was a rounding slip
```

```python
cw.finding("headline",
    f"Uruguay carries **{national:.3f} cattle per hectare** of livestock land "
    f"({int(dept['cattle'].sum()):,} cattle on {dept['livestock_ha'].sum()/1e6:.2f} million ha). "
    f"The densest department, **{dept.iloc[0]['name']}**, carries {dept.iloc[0]['density']:.2f}; "
    f"the sparsest of the big grazing departments, {dept.query('livestock_ha > 500000').iloc[-1]['name']}, "
    f"{dept.query('livestock_ha > 500000').iloc[-1]['density']:.2f}.")
```

> **Finding.** Uruguay carries **0.856 cattle per hectare** of livestock land (12,265,269 cattle on 14.32 million ha). The densest department, **San José**, carries 1.67; the sparsest of the big grazing departments, Artigas, 0.65.

```python
cc.show(cc.choropleth(dept, "density", title="Cattle per hectare of livestock land, 2025",
                      subtitle="beef + dairy (PD_AD) ÷ superficie ganadera", legend="cattle per ha",
                      fmt=".2f", domain=[0, 1.8]),
        "density-map", thumbnail=True,
        alt_text="Map: stocking density is highest in the south-west (San José, Colonia, Canelones) and lowest in the north-west (Artigas, Salto).")
print(cc.map_credit())
```

![Map: stocking density is highest in the south-west (San José, Colonia, Canelones) and lowest in the north-west (Artigas, Salto).](charts/density-map.svg)

Chart data and encoding: [charts/density-map.vl.json](charts/density-map.vl.json) (Vega-Lite).

```text
Map: Natural Earth 1:10m admin-1 (public domain, v5.1.2), joined on the DICOSE department code. Made with Natural Earth.
```

## Why: the sown pastures

The land-use table says how each holding's land is used. Code `1`,
*praderas artificiales permanentes*, is the sown, permanent pasture; code `8`
is native grassland and stubble. The July 2026 expedition found the
department ranking by density follows the share of sown pasture closely
(Spearman ρ = 0.847). We recompute it.

```python
# The query: hectares by land use per department, 2025.
use = cw.sparql("""
SELECT ?department ?use (SUM(?ha) AS ?hectares)
WHERE {
  GRAPH ?g {
    ?obs a cow:LandUseObservation ; cow:year "2025"^^xsd:gYear ;
         cow:department ?department ; cow:landUse ?use ; cow:hectares ?ha .
  }
}
GROUP BY ?department ?use
""")
use["code"] = use["department"].map(cw.dept_code)
use["use_code"] = use["use"].map(cw.dept_code)
use["hectares"] = use["hectares"].astype(float)
mix = use.pivot_table(index="code", columns="use_code", values="hectares", aggfunc="sum").fillna(0)
shares = mix.div(mix.sum(axis=1), axis=0)
dept = dept.merge(shares[["1", "8"]].rename(columns={"1": "praderas", "8": "campo_natural"}),
                  left_on="code", right_index=True)
nat = mix.sum() / mix.sum().sum()
rho = dept[["density", "praderas"]].rank().corr().iloc[0, 1]
cw.check("national share of campo natural", round(nat["8"], 3), 0.724, tol=0.0005, source="expedition 2026-07-05: 72.4%")
cw.check("national share of praderas", round(nat["1"], 3), 0.069, tol=0.0005, source="expedition 2026-07-05: 6.9%")
cw.check("Spearman ρ, density vs praderas share", round(rho, 3), 0.847, tol=0.0005, source="expedition 2026-07-05")
cw.finding("pastures",
    f"Density follows the sown pasture: across departments, Spearman ρ = **{rho:.3f}** between cattle per "
    f"hectare and the share of land under *praderas*. Nationally {nat['8']:.1%} of declared land is native "
    f"grassland and only {nat['1']:.1%} sown pasture: intensity is a thin southern veneer.")
```

```text
PASS  national share of campo natural: 0.724 vs 0.724 (within ±0.0005)  — expedition 2026-07-05: 72.4%
PASS  national share of praderas: 0.069 vs 0.069 (within ±0.0005)  — expedition 2026-07-05: 6.9%
PASS  Spearman ρ, density vs praderas share: 0.847 vs 0.847 (within ±0.0005)  — expedition 2026-07-05
```

> **Finding.** Density follows the sown pasture: across departments, Spearman ρ = **0.847** between cattle per hectare and the share of land under *praderas*. Nationally 72.4% of declared land is native grassland and only 6.9% sown pasture: intensity is a thin southern veneer.

```python
pts = alt.Chart(dept, title=alt.Title("Density follows the sown pasture",
                                      subtitle="one point per department, 2025")).mark_point(filled=True, size=70, color=cc.CATTLE).encode(
    x=alt.X("praderas:Q", title="share of land under sown pasture (praderas)", axis=alt.Axis(format=".0%")),
    y=alt.Y("density:Q", title="cattle per livestock hectare"),
    tooltip=["name:N", alt.Tooltip("praderas:Q", format=".1%"), alt.Tooltip("density:Q", format=".2f")],
)
# Label the spread-out departments; the crowded corner is in the tooltip and the table above.
labels = alt.Chart(dept).transform_filter(
    "datum.praderas > 0.09 || indexof(['Artigas', 'Durazno', 'Rivera'], datum.name) >= 0"
).mark_text(align="left", dx=6, dy=-4, fontSize=11).encode(x="praderas:Q", y="density:Q", text="name:N")
cc.show((pts + labels).properties(width=520, height=320), "density-vs-praderas",
        alt_text="Scatter: departments with more sown pasture carry more cattle per hectare; San José and Colonia top right, Artigas and Salto bottom left.")
```

![Scatter: departments with more sown pasture carry more cattle per hectare; San José and Colonia top right, Artigas and Salto bottom left.](charts/density-vs-praderas.svg)

Chart data and encoding: [charts/density-vs-praderas.vl.json](charts/density-vs-praderas.vl.json) (Vega-Lite).

## The census areas

Down at the census areas the range is wide. Each dot is one area in its
department (areas with under 100 livestock hectares are left out: a few
feedlots and dairy yards on tiny land bases would otherwise dominate the
scale; they are listed below).

```python
# An area can straddle a department line: sum it across departments, and file it under the department
# holding most of its livestock land.
cells = ae[ae["area_code"] != "0"]
home = cells.sort_values("livestock_ha").groupby("area_code").tail(1).set_index("area_code")["code"]
areas = cells.groupby("area_code")[["cattle", "livestock_ha"]].sum()
areas = areas[areas["livestock_ha"] >= 100].reset_index()
areas["name"] = areas["area_code"].map(home).map(names)
areas["density"] = areas["cattle"] / areas["livestock_ha"]
small = cells.groupby("area_code")[["cattle", "livestock_ha"]].sum().query("livestock_ha < 100 and cattle > 0")
order = dept.sort_values("density", ascending=False)["name"].tolist()
strip = alt.Chart(areas, title=alt.Title("Cattle per livestock hectare, by census area, 2025",
                                         subtitle=f"{len(areas)} areas with at least 100 livestock ha; departments ordered by overall density")).mark_tick(
    thickness=2, size=14, color=cc.CATTLE, opacity=0.7).encode(
    x=alt.X("density:Q", title="cattle per livestock hectare", scale=alt.Scale(domain=[0, 4], clamp=True)),
    y=alt.Y("name:N", sort=order, title=None),
    tooltip=["name:N", alt.Tooltip("area_code:N", title="census area"), alt.Tooltip("density:Q", format=".2f"),
             alt.Tooltip("cattle:Q", format=","), alt.Tooltip("livestock_ha:Q", format=",.0f")],
).properties(width=520, height=420)
cc.show(strip, "areas-strip",
        alt_text="Strip plot: each department's census areas as ticks along a density axis; the south-west spreads furthest right.")
top = areas.sort_values("density", ascending=False).head(5)
cc.show_table(top[["name", "area_code", "density", "cattle", "livestock_ha"]], floatfmt=",.2f", name="densest-areas")
print(f"{cells['area_code'].nunique()} census-area codes carry holdings (code 0, unplaced, excluded); "
      f"{(cells.groupby('area_code')['code'].nunique() > 1).sum()} of them straddle a department line.")
print(f"{len(small)} areas with cattle on under 100 livestock ha were left out of the strip plot.")
```

![Strip plot: each department's census areas as ticks along a density axis; the south-west spreads furthest right.](charts/areas-strip.svg)

Chart data and encoding: [charts/areas-strip.vl.json](charts/areas-strip.vl.json) (Vega-Lite).

| name      |   area_code |   density | cattle   | livestock_ha   |
|:----------|------------:|----------:|:---------|:---------------|
| Canelones |      317003 |      8.33 | 11,589   | 1,391.20       |
| San José  |     1607005 |      6.38 | 4,879    | 765.10         |
| Canelones |      305004 |      5.59 | 4,312    | 771.90         |
| Soriano   |     1704001 |      4.76 | 18,400   | 3,862.00       |
| Florida   |      805004 |      4.62 | 20,074   | 4,344.10       |

```text
634 census-area codes carry holdings (code 0, unplaced, excluded); 14 of them straddle a department line.
5 areas with cattle on under 100 livestock ha were left out of the strip plot.
```

```python
cw.check("every area cell is inside a department", int(ae["code"].isna().sum()), 0, source="cube")
cw.finding("areas",
    f"Of {len(areas)} census areas with at least 100 livestock hectares, the densest is area "
    f"**{top.iloc[0]['area_code']} in {top.iloc[0]['name']}** at {top.iloc[0]['density']:.2f} cattle per hectare; "
    f"the median area carries {areas['density'].median():.2f}.")
```

```text
PASS  every area cell is inside a department: 0 vs 0 (exactly)  — cube
```

> **Finding.** Of 629 census areas with at least 100 livestock hectares, the densest is area **317003 in Canelones** at 8.33 cattle per hectare; the median area carries 0.91.

## Where every number came from

```python
cc.show_table(cw.checks()[["check", "got", "expected", "rule", "source"]], name="checks")
cw.provenance()
```

| check                                                                  |   got |   expected | rule           | source                                                                                   |
|:-----------------------------------------------------------------------|------:|-----------:|:---------------|:-----------------------------------------------------------------------------------------|
| livestock hectares per department: cube = CSV (largest difference, ha) |  0    |       0    | exactly        | datosgenerales.csv 2025, SuperficieGanadera                                              |
| cattle per department: cube = CSV (largest difference, heads)          |  0    |       0    | exactly        | datosanimales.csv 2025                                                                   |
| national livestock hectares, millions                                  | 14.32 |      14.32 | within ±0.005  | expedition 2026-07-05: 14.32M ha                                                         |
| national density, cattle per livestock ha                              |  0.86 |       0.86 | within ±0.0005 | expedition 2026-07-05: 0.856                                                             |
| San José density                                                       |  1.67 |       1.67 | within ±0.005  | expedition 2026-07-05                                                                    |
| all 19 densities = the cube's derived metric (largest gap)             |  0    |       0    | within ±1e-06  | cow:metric_bovine_per_livestock_hectare, materialised at ingest                          |
| Artigas density                                                        |  0.65 |       0.65 | within ±5e-05  | the July expedition printed 0.66; the exact value is 0.6547, so that was a rounding slip |
| national share of campo natural                                        |  0.72 |       0.72 | within ±0.0005 | expedition 2026-07-05: 72.4%                                                             |
| national share of praderas                                             |  0.07 |       0.07 | within ±0.0005 | expedition 2026-07-05: 6.9%                                                              |
| Spearman ρ, density vs praderas share                                  |  0.85 |       0.85 | within ±0.0005 | expedition 2026-07-05                                                                    |
| every area cell is inside a department                                 |  0    |       0    | exactly        | cube                                                                                     |

```text
     kind        target  rows  \
0  sparql  uruguay-cows   660   
1  sparql  uruguay-cows   667   
2  sparql  uruguay-cows    19   
3  sparql  uruguay-cows   152   

                                          first line  
0      SELECT ?department ?area (SUM(?n) AS ?cattle)  
1  SELECT ?department ?area (SUM(?ha) AS ?livesto...  
2                          SELECT ?department ?value  
3    SELECT ?department ?use (SUM(?ha) AS ?hectares)
```

```python
cw.save()
```
