A 75-unit portfolio managed the way most operators actually manage it - gut feel at renewal, quick Zillow checks when a unit goes vacant, no systematic process - typically leaves 3-8% of potential NOI on the table annually. On a $180,000 annual gross rent roll, that is $5,400 to $14,400 per year in foregone income. Over a 5-year hold period, that gap compounds into a meaningful drag on returns and a lower exit valuation because NOI is what drives your cap rate sale price.
This post covers the full portfolio rent optimization workflow - from pulling your lease roll to generating a prioritized action queue - using the RentComp API for batch comp pulls and Python for the analysis layer. For context on automating individual pricing decisions, see our post on automating rent pricing with market data. For investment analysis applications, see real estate investment analysis with rental data APIs.
The Scale Problem: Why Manual Pricing Fails at 50+ Units
At 10 units, a diligent operator can check market rent manually for each renewal. It takes time but it is tractable. At 50 units, the math breaks down. If you have 50 units with staggered 12-month leases, you have roughly 4-5 renewals per month. Each requires checking current market, assessing unit condition, calculating the trade-off between higher rent and turnover risk, and communicating with the tenant. Doing this properly for each unit takes 30-45 minutes. That is 2-3 hours of pricing work every month just to tread water - and most operators do not do it, which is why underpricing is so common.
The systematic approach compresses this to 20 minutes per month for a 50-unit portfolio. You run the batch analysis once a week, review the output CSV, make any overrides based on local knowledge, and hand the renewal team a prioritized action list. The API handles the data collection; you handle the judgment.
The Portfolio Rent Audit Workflow
Step 1: Pull the Active Lease Roll
Export your lease roll from your PM system with at minimum these fields per unit:
- Full property address and unit number
- Bedrooms, bathrooms, square footage
- Current monthly rent
- Lease end date
- Unit status (active lease vs vacant vs pending)
Most PM systems (Buildium, AppFolio, Rent Manager, Yardi) can export this as CSV in under 2 minutes. Save it as lease_roll.csv.
Step 2: Batch Comp Pulls with Async Python
For 50-200 units, sequential API calls work but take 2-5 minutes. For 200+ units, use asyncio + aiohttp for parallel calls. The rate limit is 10 concurrent requests, which cuts processing time by roughly 8-9x in practice.
import asyncio
import aiohttp
import csv
import json
from datetime import datetime, timedelta
from pathlib import Path
RENTCOMP_API_KEY = "your_api_key"
RENTCOMP_BASE = "https://api.rentcompapi.com/v1"
CONCURRENCY = 8 # stay under the 10 concurrent request limit
async def fetch_comps(session, semaphore, row):
"""Fetch comps for one unit row from lease roll."""
async with semaphore:
address = row["address"]
unit = row.get("unit", "")
full_address = f"{address} Unit {unit}".strip() if unit else address
payload = {
"address": full_address,
"bedrooms": int(row.get("bedrooms", 1)),
"bathrooms": float(row.get("bathrooms", 1)),
"sqft": int(str(row.get("sqft", 800)).replace(",", "")),
"radius_miles": 1.0,
"max_age_days": 90,
}
try:
async with session.post(
f"{RENTCOMP_BASE}/comps",
json=payload,
headers={"Authorization": f"Bearer {RENTCOMP_API_KEY}"},
timeout=aiohttp.ClientTimeout(total=20),
) as resp:
if resp.status != 200:
return {**row, "error": f"HTTP {resp.status}",
"market_median": None, "confidence": 0}
data = await resp.json()
stats = data.get("market_stats", {})
return {
**row,
"market_median": stats.get("median"),
"market_p25": stats.get("p25"),
"market_p75": stats.get("p75"),
"confidence": data.get("confidence_score", 0),
"comp_count": data.get("comp_count", 0),
"error": None,
}
except Exception as e:
return {**row, "error": str(e), "market_median": None,
"confidence": 0}
async def batch_portfolio_comps(lease_roll_path: str) -> list:
"""Read lease roll CSV and fetch comps for all active units."""
rows = []
with open(lease_roll_path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
# Normalize column names to lowercase
rows.append({k.lower().strip(): v.strip()
for k, v in row.items()})
print(f"Processing {len(rows)} units...")
semaphore = asyncio.Semaphore(CONCURRENCY)
async with aiohttp.ClientSession() as session:
tasks = [fetch_comps(session, semaphore, row) for row in rows]
results = await asyncio.gather(*tasks)
return results
def calculate_portfolio_analysis(results: list) -> list:
"""Add gap analysis and action classification to each unit."""
analyzed = []
for r in results:
current_rent = float(str(r.get("current_rent", 0)).replace(",", ""))
market_median = r.get("market_median")
confidence = r.get("confidence", 0)
lease_end_str = r.get("lease_end_date", "")
# Parse lease end date
lease_end = None
for fmt in ("%m/%d/%Y", "%Y-%m-%d", "%m/%d/%y"):
try:
lease_end = datetime.strptime(lease_end_str, fmt)
break
except (ValueError, TypeError):
continue
days_to_renewal = None
if lease_end:
days_to_renewal = (lease_end - datetime.today()).days
if market_median and current_rent > 0 and confidence >= 60:
price_gap = market_median - current_rent
gap_pct = price_gap / current_rent * 100
if gap_pct > 10:
action = "INCREASE >10%"
priority = 1
elif gap_pct > 5:
action = "RENEWAL TARGET"
priority = 2
elif gap_pct >= -2:
action = "HOLD"
priority = 3
else:
action = "AT RISK - REVIEW" # current rent > market
priority = 4
else:
price_gap = None
gap_pct = None
action = "LOW DATA - MANUAL REVIEW"
priority = 5
# Boost priority for units renewing soon
if days_to_renewal is not None and days_to_renewal <= 60:
priority = max(1, priority - 1)
analyzed.append({
**r,
"current_rent": current_rent,
"market_median": market_median,
"price_gap": round(price_gap, 2) if price_gap is not None else None,
"gap_pct": round(gap_pct, 1) if gap_pct is not None else None,
"days_to_renewal": days_to_renewal,
"action": action,
"priority": priority,
})
# Sort by priority then absolute dollar gap descending
analyzed.sort(key=lambda x: (
x["priority"],
-(abs(x["price_gap"]) if x["price_gap"] else 0)
))
return analyzed
def write_portfolio_report(results: list, output_path: str):
"""Write the final analysis to CSV."""
if not results:
print("No results to write.")
return
fieldnames = [
"address", "unit", "bedrooms", "bathrooms", "sqft",
"current_rent", "market_median", "market_p25", "market_p75",
"price_gap", "gap_pct", "confidence", "comp_count",
"lease_end_date", "days_to_renewal", "action", "priority", "error"
]
with open(output_path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(results)
# Print summary stats
total = len(results)
increase_10 = sum(1 for r in results if r["action"] == "INCREASE >10%")
renewal_target = sum(1 for r in results if r["action"] == "RENEWAL TARGET")
total_gap = sum(r["price_gap"] for r in results
if r["price_gap"] and r["price_gap"] > 0)
print(f"\nPortfolio Summary ({total} units):")
print(f" Increase >10%: {increase_10} units")
print(f" Renewal target (5-10%): {renewal_target} units")
print(f" Total monthly revenue gap: ${total_gap:,.0f}")
print(f" Annual revenue gap: ${total_gap * 12:,.0f}")
print(f"\nOutput written to {output_path}")
# Main entry point
async def main():
results = await batch_portfolio_comps("lease_roll.csv")
analyzed = calculate_portfolio_analysis(results)
write_portfolio_report(analyzed, "portfolio_rent_analysis.csv")
asyncio.run(main())
The Unit Prioritization Matrix
The action queue is not just a list sorted by gap percentage. The most important variable is urgency - a 12% gap on a unit renewing in 45 days needs to be acted on this week. A 15% gap on a unit that just signed a 12-month lease is informational only.
The prioritization matrix combines price_gap with days_to_renewal:
- Tier 1 - Act now: Gap above 10% AND renewal within 60 days. These are your highest-dollar opportunities with immediate action windows.
- Tier 2 - Plan for renewal: Gap 5-10% AND renewal within 90 days. Queue these for standard renewal letters with a modest increase.
- Tier 3 - Monitor: Gap above 5% but renewal 90+ days out. Note these for the next analysis run.
- Tier 4 - Vacancy risk: Current rent more than 5% above market median. These units are at risk of vacancy at renewal. Consider holding rent flat to retain the tenant.
Portfolio-Level Analytics for Investor Reports
The batch output gives you the unit-level data, but ownership and investors care about portfolio-level metrics. The three numbers that matter most:
Revenue gap: Sum of all positive price gaps across the portfolio. This is the total monthly revenue you are leaving on the table if every underpriced unit were brought to market. A 75-unit portfolio with an average $90 gap is leaving $6,750/month or $81,000/year on the table.
Top 10 underpriced units by absolute dollar gap: These are the highest-priority units by NOI impact. Include address, current rent, market median, and days to renewal. This is the action list that goes to the property manager.
Vacancy drag: Units that have been vacant more than 21 days are likely overpriced for current market conditions. Flag these with their market median - the right question is not "what is market" but "what is market minus one month's concession to clear it fast."
Seasonal Pricing Strategy for Portfolio Operators
One underappreciated lever for portfolio operators is lease expiration timing. If all your leases expire in December and January, you are forced to re-lease in the softest rental months of the year. The rental market in most metros runs 8-12% softer in November through February than in May through August. Staggering lease expirations so that 60-70% expire in spring and summer can be worth more than any individual rent increase.
The practical approach: when a tenant renews mid-cycle, offer a slight discount (1-2%) for a 14-month or 15-month lease rather than a 12-month lease. This shifts their next renewal into the spring premium window. Over 2-3 lease cycles, you can meaningfully rebalance your expiration calendar without forcing any tenant out.
When to Override the API Recommendation
The API gives you the market anchor. It does not know everything about your specific unit. Override the recommendation when:
- You recently renovated: New cabinets, flooring, or appliances typically support a $75-150/month premium over comparable unrenovated units. The comp pool may not reflect this if comps are unrenovated.
- You have a high-quality long-term tenant: A tenant who has been in place 4 years with zero late payments and no maintenance calls is worth keeping at market minus $50-75/month. Turnover costs $1,500-2,500 in make-ready + lost rent days.
- A major local employer just announced changes: If Amazon is opening a distribution center 2 miles away, the comps from 60 days ago do not reflect where rents are going. Get ahead of it.
- New construction is delivering nearby: 200 new luxury units completing 4 blocks away will suppress comps within your radius for 6-12 months as those units lease up. The API will capture this lag - you need to anticipate it.
The NOI math: On a 75-unit portfolio with a 6.5% cap rate, every $100/month of additional rent per unit is worth $100 x 12 months x 75 units / 0.065 = $1,384,615 in asset value at exit. Getting your rent roll to market is not a maintenance task - it is a wealth creation task.
Ready to Pull Rental Comps via API?
Join the waitlist and get 80% off founding member pricing - for life.
Join the Waitlist