# Baltimore City Permits & Property Data (`j0401/md-baltimore-permits`) Actor

Baltimore City building permits (public data, 292,819 permits) and real-property records (238,148 parcels) in one actor, linked by block/lot: who pulled a permit for what and where, plus owner, sale price, year built, assessed values and zoning for the parcel.

- **URL**: https://apify.com/j0401/md-baltimore-permits.md
- **Developed by:** [Wenhao Yang](https://apify.com/j0401) (community)
- **Categories:** Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.03 / 1,000 baltimore permit or property records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Baltimore City Permits & Property Data

### Low cost

**From $0.00005 per record, down to $0.00003 at Gold** - pay per record delivered, and nothing for the query. Each record costs a fraction of a cent.

Two Baltimore City datasets over the same parcels: what the city has permitted, and what sits on the land.

### What you get

| Corpus | Rows | What it holds |
|---|---|---|
| **permits** (default) | 292,819 | building permits: case number, description, issue / expiration dates, address, block/lot, existing and proposed use, council district, neighbourhood, cost, permit name |
| **property** | 238,148 | the real-property roll: PIN and block/lot, up to three owner names, property and mailing address, deed book/page, sale date and price, year built, structure area, land / improvement / full-cash values with exemptions, use group, zoning code, neighbourhood, vacant flag |

### Why two corpora is the point

Both tables carry the city's **block/lot** parcel key, and both are populated on **100%** of rows. That turns two datasets into one answer - in two calls: this lot is zoned R-6, has this owner, last sold for this much, was built in this year, *and* here are the fourteen permits pulled on it.

Neither dataset alone tells you that. The permit record knows the work but not the owner or the value; the roll knows the parcel but not what was done to it. Both sides carry the `blocklot` key, so you join them yourself - the permit records do not repeat the owner or the value, and the roll does not carry the permits.

### Modes

- **permits** (default) - newest permits first
- **property** - parcels, highest sale price first
- **aggregate** - one count row per group: by neighbourhood, use, district (permits) or use group, zoning, vacancy (property)

Filter permits by case number, address, neighbourhood, council district, existing/proposed use, permit name or issue date. Filter property by owner, address, neighbourhood, ZIP, zoning code, use group, vacancy, year built, sale price or sale date. A filter that belongs to the other corpus is rejected rather than quietly ignored.

### Example inputs

**One parcel's property record** - `blocklot` is the city's parcel key, and it
works in both corpora.

```json
{ "corpus": "property", "blocklot": "3881 030" }
```

**Every permit filed on that same lot** - switch the corpus, keep the key.

```json
{ "corpus": "permits", "blocklot": "3881 030", "maxResults": 50 }
```

**Buildings the city has flagged vacant** - a Y/N filter selects only the
flagged subset, not every vacant building; the parcels with no flag at all form
their own `unrecorded` bucket in a `vacant` rollup rather than being counted as
vacant.

```json
{ "corpus": "property", "vacant": "Y", "maxResults": 100 }
```

**A neighbourhood's zoning mix** - one count per group over the parcels.

```json
{ "corpus": "property", "neighborhood": "Fells Point",
  "aggregate": true, "groupBy": "zoneCode" }
```

### Why this is hard

**Two dates in this data are strings, and the format is not the one you would guess.** Sale date and layer-update date arrive as `MMDDYYYY` - `09202026` means 20 September 2026, and reads as a nonsense year if you parse it as anything else.

**The vacant flag is mostly absent, and that matters.** About 209,000 of the 238,148 parcels carry no flag at all - neither Y nor N, and the aggregate reports that bucket as `unrecorded` rather than leaving it blank. Filtering for vacant buildings gives you the 11,371 that are *flagged* vacant, not "every vacant building", and a product that blurred the two would be quietly wrong.

**Zoning codes are padded with trailing blanks.** `R-8` is stored as `R-8  `. The good news is that equality already ignores the padding, so `R-8` finds all 71,951 rows - but anything that tries to *normalise* the value first (trimming, concatenating, prefixing) fails against this service and takes the filter down with it.

**And the parcel tail is not representative.** 7,290 parcels carry no address at all, and the most recently loaded rows are where they cluster - including 137 `PSC*` records that are entirely blank. Ordering the default by load order opens on rows with nothing to show, so the parcel view is ordered by sale price instead, which opens on records that actually carry data - though a handful of blank-address rows survive even there, since a sale price is not an address.

**Zero is not a year and not a price.** 39,847 parcels have no year built recorded and 68,682 have never sold, and both are stored as `0` rather than blank. A naive "built before 1900" would return 67,674 rows, two thirds of them buildings whose age nobody knows; a naive price ceiling would sweep in every never-sold parcel. Any bound here excludes the zeros, and the fields come back empty rather than as a literal zero.

### A note on what is *not* here

This actor covers permits and property only. Baltimore *does* publish citations - the Environmental Control Board violation ledger (153k rows, with fine amounts and balances) - and we ship it as a **separate actor**, `md-balt-ecb-citations`, rather than folding it in here.

Worth flagging one common mix-up: a large "blight tickets" dataset is often mistaken for Baltimore's. It is Detroit's, filed under a Detroit agency, and is not used anywhere in this actor.

### Output

One schema across both corpora: every record carries the full field set, empty where that corpus has nothing to say, so the same downstream code reads either. Dates arrive as `YYYY-MM-DD`, and each record carries `sourceUpdatedAt`, the layer's own last-refresh date.

### Example output

**One parcel** - `corpus=property`, `blocklot=3881 030` returns that parcel's
roll record (the permit fields come back empty, because the roll does not carry
them). Swap `corpus` to `permits` and the same key returns the permits filed on
the lot. Every record carries `platform`, `source`, and the aggregate columns
`groupKey` / `groupCount` / `groupBy`, which are `""` outside aggregate mode:

```json
{
  "platform": "md-baltimore-permits-property",
  "source": "baltimore-city-open-data",
  "groupKey": "", "groupCount": "", "groupBy": "",
  "address": "3119 BARCLAY ST",
  "blocklot": "3881 030",
  "caseNumber": "",
  "corpus": "property",
  "cost": "",
  "councilDistrict": "",
  "deedBook": "LGA18350",
  "deedPage": "0317",
  "description": "",
  "dwellingUnits": "1",
  "exemptImprovement": "0",
  "exemptLand": "0",
  "existingUse": "",
  "expirationDate": "",
  "fullCashValue": "0",
  "improvementValue": "9500",
  "issuedDate": "",
  "landValue": "60000",
  "lotSize": "21X110",
  "mailingAddress": "P.O. BOX 16013, 21218",
  "neighborhood": "ABELL",
  "owner1": "BARCLAY, LLC",
  "owner2": "",
  "owner3": "",
  "permitName": "",
  "pin": "3881030",
  "proposedUse": "",
  "saleDate": "2016-08-08",
  "salePrice": "45025",
  "sourceUpdatedAt": "2026-09-27",
  "structureArea": "1120",
  "useGroup": "R",
  "vacant": "Y",
  "yearBuilt": "1900",
  "zipCode": "21218",
  "zoneCode": "R-7"
}
```

### Notes

- Public open data from Baltimore City's open GIS. No login, no scraping.
- Charges are metered per record delivered, so a parcel lookup costs a fraction of a cent.

# Actor input Schema

## `corpus` (type: `string`):

permits (default) = building permits issued by the city. property = the real-property roll for each parcel. Both carry block/lot, so a parcel lookup spans both.

## `blocklot` (type: `string`):

The city's block/lot parcel key, e.g. 0654 009. Works in both modes - use it to pull a parcel's property record and every permit on it.

## `caseNumber` (type: `string`):

Permit mode only. Case number substring, e.g. BCCM-25-000001 or BRCM-25-015116.

## `address` (type: `string`):

Street-address substring, case-insensitive. Works in both modes (permit address or property address).

## `owner` (type: `string`):

Property mode only. Owner-name substring, matched against all three owner slots on the roll.

## `neighborhood` (type: `string`):

Neighbourhood substring, e.g. Canton, Hampden, Belair-Edison. Works in both modes.

## `councilDistrict` (type: `string`):

Permit mode only. City council district number (1-14).

## `proposedUse` (type: `string`):

Permit mode only. Exact source value, e.g. 'Dwelling: Rowhouse', 'Single Family Dwelling', 'Commercial'. Values are the city's own use codes, matched exactly.

## `existingUse` (type: `string`):

Permit mode only. Exact source value for the use before the work, e.g. 'Dwelling: Rowhouse'.

## `permitName` (type: `string`):

Permit mode only. Project / permit-name substring, e.g. FENCE, or a development name.

## `issuedFrom` (type: `string`):

Permit mode only. Only permits issued on/after this date (YYYY-MM-DD).

## `issuedTo` (type: `string`):

Permit mode only. Only permits issued on/before this date (YYYY-MM-DD).

## `zip` (type: `string`):

Property mode only. Property ZIP prefix, e.g. 212 or 21230.

## `zoneCode` (type: `string`):

Property mode only. Zoning code, e.g. R-8, R-6, C-1. The source pads these with trailing blanks; equality already ignores the padding, so the plain code matches.

## `useGroup` (type: `string`):

Property mode only. Assessor use group, e.g. R (residential), C (commercial), E, U, I.

## `vacant` (type: `string`):

Property mode only. Y = flagged vacant (~11k), N = flagged occupied (~18k). Most parcels carry no flag at all, so a Y/N filter selects only the flagged subset.

## `minYearBuilt` (type: `integer`):

Property mode only. Only structures built in or after this year.

## `maxYearBuilt` (type: `integer`):

Property mode only. Only structures built in or before this year.

## `minSalePrice` (type: `integer`):

Property mode only. Only parcels whose last recorded sale price is at or above this amount.

## `maxSalePrice` (type: `integer`):

Property mode only. Only parcels whose last recorded sale price is at or below this amount.

## `soldFrom` (type: `string`):

Property mode only. Only parcels last sold on/after this date (YYYY-MM-DD).

## `soldTo` (type: `string`):

Property mode only. Only parcels last sold on/before this date (YYYY-MM-DD).

## `aggregate` (type: `boolean`):

When on, returns one summary record per group (see groupBy) with count - instead of individual records.

## `groupBy` (type: `string`):

Which dimension to aggregate over. Blank picks one that suits the chosen corpus. permits: neighborhood / proposedUse / existingUse / councilDistrict. property: useGroup / zoneCode / neighborhood / vacant.

## `maxResults` (type: `integer`):

Cap the number of records pushed (0 = up to ~10k per run; each record is metered individually, so there is no per-run charge cap). Does not apply in aggregate mode, which returns every group of the chosen dimension.

## Actor input object example

```json
{
  "corpus": "permits",
  "blocklot": "",
  "caseNumber": "",
  "address": "",
  "owner": "",
  "neighborhood": "",
  "councilDistrict": "",
  "proposedUse": "",
  "existingUse": "",
  "permitName": "",
  "issuedFrom": "",
  "issuedTo": "",
  "zip": "",
  "zoneCode": "",
  "useGroup": "",
  "vacant": "",
  "soldFrom": "",
  "soldTo": "",
  "aggregate": false,
  "groupBy": "",
  "maxResults": 50
}
```

# Actor output Schema

## `recordsUrl` (type: `string`):

Baltimore City building-permit records, real-property records, or aggregates - as JSON

## `datasetUrl` (type: `string`):

No description

## `runUrl` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("j0401/md-baltimore-permits").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("j0401/md-baltimore-permits").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call j0401/md-baltimore-permits --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,j0401/md-baltimore-permits"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/t6W2NIzR1TFAL2T45/builds/HdCDYWazpDRv9JVcA/openapi.json
