Real Estate Analysis: Market Filtering
Filter residential properties by price, size, and proximity to schools to identify optimal investment opportunities.
Scenario Overview
Goal: Find single-family homes priced $200k-$400k, >150m², within 1km of highly-rated schools.
Real-World Application:
- Real estate investors finding properties matching criteria
- Home buyers searching for family-friendly neighborhoods
- Real estate agents providing data-driven recommendations
- Market analysts evaluating property values vs. amenities
Estimated Time: 8 minutes
Difficulty: ⭐ Beginner
Prerequisites
Required Data
-
Residential Properties Layer (points or polygons)
- Property listings or parcel data
- Required attributes:
price(numeric)area_sqmorliving_area(numeric)property_type(text: 'single_family', 'condo', etc.)
- Optional:
bedrooms,bathrooms,year_built
-
Schools Layer (points)
- School locations
- Optional but useful:
rating,school_level,name - Covers your study area
Sample Data Sources
Real Estate Data:
- MLS (Multiple Listing Service) exports
- Zillow/Trulia data feeds (if available)
- Municipal property assessment databases
- OpenStreetMap buildings with tags
Schools Data:
# QGIS QuickOSM plugin
Key: "amenity", Value: "school"
Key: "school", Value: "*"
# Or government data:
- National Center for Education Statistics (USA)
- Department for Education (UK)
- Local education authority databases
Backend Recommendation
Multi-Backend Comparison - This workflow demonstrates all three:
- PostgreSQL: Fastest if you have >10k properties
- Spatialite: Good middle ground for city-scale data
- OGR: Works everywhere, acceptable performance for <5k properties
Step-by-Step Instructions
Step 1: Load and Inspect Property Data
-
Load properties layer:
residential_properties.gpkg -
Open Attribute Table (F6)
-
Verify required fields exist:
✓ price (numeric)
✓ area_sqm (numeric)
✓ property_type (text) -
Check data quality:
Sort by price: Look for unrealistic values (0, NULL, >$10M)
Sort by area: Check for 0 or NULL values
Filter property_type: Identify valid categories
If you have missing values:
-- Filter out incomplete records FIRST
"price" IS NOT NULL
AND "area_sqm" > 0
AND "property_type" IS NOT NULL
Step 2: Apply Basic Attribute Filters
Using FilterMate:
- Open FilterMate panel
- Select residential_properties layer
- Choose any backend (attribute filtering works equally on all)
- Enter expression:
- Basic Filter
- Advanced (Multiple Types)
- Investment Focused
-- Price between $200k and $400k
-- Area greater than 150m²
-- Single-family homes only
"price" >= 200000
AND "price" <= 400000
AND "area_sqm" >= 150
AND "property_type" = 'single_family'
-- Accept multiple property types
"price" BETWEEN 200000 AND 400000
AND "area_sqm" >= 150
AND "property_type" IN ('single_family', 'townhouse')
AND "bedrooms" >= 3
-- Find undervalued properties (price per sqm)
"price" BETWEEN 200000 AND 400000
AND "area_sqm" >= 150
AND "property_type" = 'single_family'
AND ("price" / "area_sqm") < 2000 -- Less than $2000/m²
- Click Apply Filter
- Review count: "Showing X of Y features"
Expected Result: Properties narrowed down by price, size, and type
Step 3: Add Spatial Filter for School Proximity
Now add the location-based criterion:
- Ensure schools layer is loaded:
schools.gpkg - Modify FilterMate expression to add spatial component:
- OGR / Spatialite
- PostgreSQL
-- Combine attribute filters + spatial proximity
"price" >= 200000
AND "price" <= 400000
AND "area_sqm" >= 150
AND "property_type" = 'single_family'
AND distance(
$geometry,
aggregate(
layer:='schools',
aggregate:='collect',
expression:=$geometry
)
) <= 1000
Alternative using overlay functions:
-- Same criteria + check any school within 1km exists
"price" BETWEEN 200000 AND 400000
AND "area_sqm" >= 150
AND "property_type" = 'single_family'
AND array_length(
overlay_within(
'schools',
buffer($geometry, 1000)
)
) > 0
-- Using PostGIS spatial functions
price >= 200000
AND price <= 400000
AND area_sqm >= 150
AND property_type = 'single_family'
AND EXISTS (
SELECT 1
FROM schools s
WHERE ST_DWithin(
properties.geom,
s.geom,
1000 -- 1km in meters
)
)
Or with distance calculation:
-- Include distance to nearest school as output
SELECT
p.*,
MIN(ST_Distance(p.geom, s.geom)) AS distance_to_school
FROM properties p
JOIN schools s ON ST_DWithin(p.geom, s.geom, 1000)
WHERE price BETWEEN 200000 AND 400000
AND area_sqm >= 150
AND property_type = 'single_family'
GROUP BY p.property_id
- Click Apply Filter
- Review results on map (should be concentrated near schools)
Step 4: Refine by School Quality (Optional)
If your schools layer has rating data:
-- Only properties near highly-rated schools (rating ≥ 8/10)
"price" BETWEEN 200000 AND 400000
AND "area_sqm" >= 150
AND "property_type" = 'single_family'
AND array_max(
array_foreach(
overlay_within('schools', buffer($geometry, 1000)),
attribute(@element, 'rating')
)
) >= 8
What this does:
- Finds all schools within 1km buffer
- Gets their rating values
- Keeps properties where at least one nearby school has rating ≥8
Step 5: Calculate Distance to Nearest School
Add field showing exact distance:
-
Open Field Calculator (Ctrl+I) on filtered layer
-
Create new field:
Field name: nearest_school_m
Type: Decimal (double)
Precision: 1
Expression:
round(
array_min(
array_foreach(
overlay_nearest('schools', $geometry, limit:=1),
distance(geometry(@element), $geometry)
)
),
0
) -
Add school name (optional):
Field name: nearest_school_name
Type: Text (string)
Expression:
attribute(
overlay_nearest('schools', $geometry, limit:=1)[0],
'name'
)
Step 6: Rank Properties by Value
Create a value score combining multiple factors:
-
Open Field Calculator
-
Create calculated field:
Field name: value_score
Type: Decimal (double)
Expression:
-- Higher score = better value
-- Weighted factors:
(400000 - "price") / 1000 * 0.4 + -- Lower price = better (40% weight)
("area_sqm" - 150) * 0.3 + -- Larger area = better (30% weight)
(1000 - "nearest_school_m") * 0.3 -- Closer school = better (30% weight) -
Sort by value_score descending to see best deals first
Step 7: Visualize Results
Color by Distance to School:
- Right-click layer → Symbology
- Choose Graduated
- Value:
nearest_school_m - Method: Natural Breaks
- Colors: Green (close) → Yellow → Red (far)
Add Labels:
Label with: concat('$', "price"/1000, 'k - ', round("nearest_school_m",0), 'm to school')
Size: 10pt
Buffer: White, 1mm
Step 8: Export Matches for Analysis
-
In FilterMate: Click Export Filtered Features
Format: GeoPackage
Filename: properties_investment_targets.gpkg
CRS: WGS84 (for portability)
Include all attributes: ✓ -
Export attribute table as spreadsheet:
Right-click layer → Export → Save Features As
Format: CSV or XLSX
Fields: Select relevant columns only -
Create simple report (optional):
# Python Console
layer = iface.activeLayer()
features = list(layer.getFeatures())
print("=== Property Investment Report ===")
print(f"Matching properties: {len(features)}")
print(f"Average price: ${sum(f['price'] for f in features)/len(features):,.0f}")
print(f"Average area: {sum(f['area_sqm'] for f in features)/len(features):.0f} m²")
print(f"Average distance to school: {sum(f['nearest_school_m'] for f in features)/len(features):.0f} m")
print(f"Price range: ${min(f['price'] for f in features):,} - ${max(f['price'] for f in features):,}")
Understanding the Results
What the Filter Shows
✅ Selected properties: Match ALL criteria:
- Price: $200,000 - $400,000
- Size: ≥150m²
- Type: Single-family home
- Location: ≤1km from school
❌ Excluded properties: Fail ANY criterion above
Interpreting Property Matches
High Value Score (>500):
- Below-market pricing for area
- Good size for price point
- Very close to school (family appeal)
- Action: Priority viewing/offer
Medium Score (250-500):
- Fair market value
- Acceptable location
- Consider other factors (condition, neighborhood)
- Action: Compare with similar properties
Low Score (<250):
- May be overpriced
- Far edge of school proximity
- Smaller size for price
- Action: Negotiate or wait for better options
Quality Checks
-
Sanity check: View 5-10 random results
- Verify prices are realistic
- Measure school distance manually
- Check property_type matches expectations
-
Outlier detection:
-- Find unusually cheap properties (may be errors or great deals)
"price" / "area_sqm" < 1500 -- Less than $1500/m² -
Map patterns: Results should cluster near schools (if not, check CRS)
Best Practices
Search Strategy Refinement
Start Broad, Narrow Gradually:
- First pass: Apply only price + size filters
- Review count: If >100 results, add property_type filter
- Add spatial: Apply school proximity
- Fine-tune: Add school rating, bedrooms, etc.
Save Filter History:
- FilterMate automatically saves your expressions
- Use Filter History panel to compare different criteria sets
- Save best performing filters as Favorites
Performance Considerations
Backend Selection Guide:
Properties | Schools | Recommended Backend
-----------|---------|--------------------
< 1,000 | Any | OGR (simplest)
1k - 10k | < 100 | Spatialite
> 10k | Any | PostgreSQL
Any | > 500 | PostgreSQL + spatial index
Optimization Tips:
-
Apply attribute filters first (cheapest):
-- Good: Attributes first, spatial last
"price" BETWEEN 200000 AND 400000 AND distance(...) <= 1000
-- Bad: Spatial first (slower)
distance(...) <= 1000 AND "price" BETWEEN 200000 AND 400000 -
Use spatial index (automatic in PostgreSQL, create manually for Spatialite):
Layer Properties → Create Spatial Index -
Simplify school geometry if complex:
Vector → Geometry → Centroids (schools → points)
Real Estate Best Practices
Market Analysis:
- Run this filter weekly to track new listings
- Compare value_score trends over time
- Export results with timestamps for historical analysis
Price Adjustment:
-- Adjust for inflation or market changes
"price" * 1.05 BETWEEN 200000 AND 400000 -- +5% market growth
Seasonal Patterns:
-- School proximity more valuable in spring (family moving season)
-- Adjust weight in value_score calculation
Common Issues
Issue 1: No results or very few results
Cause: Criteria too strict or data quality issues
Solutions:
1. Relax price range: 150k-500k instead of 200k-400k
2. Reduce minimum area: 120m² instead of 150m²
3. Increase school distance: 2000m instead of 1000m
4. Check for NULL values in attributes
5. Verify schools layer covers same area as properties
Issue 2: Distance calculation returns errors
Cause: CRS mismatch or layer not found
Solution:
1. Verify schools layer name matches exactly (case-sensitive)
2. Check both layers use same CRS (reproject if needed)
3. Ensure schools layer is in current project
4. Try simpler aggregate approach:
distance(
$geometry,
aggregate('schools', 'collect', $geometry)
) <= 1000
Issue 3: Performance slow (>30 seconds)
Cause: Large dataset or complex spatial query
Solutions:
1. Switch to PostgreSQL backend (major speedup)
2. Create spatial index on both layers
3. Pre-filter properties to smaller region:
"city" = 'Boston' AND [rest of expression]
4. Reduce school query complexity:
- Use buffer once: overlay_within('schools', buffer($geometry, 1000))
- Cache in temporary field
Issue 4: Results not near schools visually
Cause: CRS using degrees instead of meters
Solution:
1. Check layer CRS: Properties → Information
2. If EPSG:4326 (lat/lon), reproject to local UTM:
Vector → Data Management → Reproject Layer
3. Update distance from 1000 to 0.01 if using degrees (not recommended)
Next Steps
Related Workflows
- Urban Planning Transit: Similar proximity analysis
- Emergency Services: Inverse distance queries
- Transportation Planning: Export and CRS handling
Advanced Techniques
1. Multi-Amenity Scoring (schools + parks + shopping):
-- Properties near multiple amenities
array_length(overlay_within('schools', buffer($geometry, 1000))) > 0
AND array_length(overlay_within('parks', buffer($geometry, 500))) > 0
AND array_length(overlay_within('shops', buffer($geometry, 800))) > 0
2. Appreciation Potential (combine demographics):
-- Areas with improving demographics
"median_income_2023" > "median_income_2020" * 1.1 -- 10% income growth
AND distance(centroid, aggregate('new_developments', 'collect', $geometry)) < 2000
3. Commute Time Analysis (requires road network):
Processing → Network Analysis → Service Area
Origin: Properties
Destination: Employment centers
Time limit: 30 minutes
4. Market Comparison (price per sqm by neighborhood):
-- Find properties below neighborhood average
"price" / "area_sqm" <
aggregate(
layer:='all_properties',
aggregate:='avg',
expression:="price"/"area_sqm",
filter:="neighborhood" = attribute(@parent, 'neighborhood')
) * 0.9 -- 10% below average
5. Time-Series Tracking (monitor listing duration):
-- Properties on market >30 days (motivated sellers)
"days_on_market" > 30
AND "price_reduced" = 1