!

Loading Canadian property data into Snowflake with Houski

Photo of Alex Wilkinson
Alex Wilkinson
CEO of Houski
2025-03-17

Most data teams already have a Snowflake warehouse. What they do not have is a clean, structured feed of Canadian property data sitting next to their internal tables.

This is a step-by-step guide for data engineers who want to land Houski's full Canadian property dataset into Snowflake, with two paths depending on freshness needs: bulk file delivery for the whole stock, and a live API path for the deltas.

What Houski delivers

Houski maintains a unified record for every property in Canada. Each row has identification fields, structural attributes, assessment history, listing history, demographic context drawn from public, government, and crowdsourced data sources, and estimates from our valuation models. Bulk data is delivered as Arrow or Parquet files in an S3 bucket your team can read with Snowflake's external stage. For on-demand queries, the same fields are reachable through the properties endpoint.

You generally want both. Bulk for the cold storage history and heavy joins. Live API for everything that moved in the last 24 hours.

Step 1: Get the bulk dataset

Bulk delivery is a custom arrangement our data team sets up with you - ask us to scope one. As an example of what we can deliver: an S3 prefix your team reads from, synced on whatever cadence you need (daily, weekly, monthly), partitioned by province with a manifest at the root. Parquet is the default for Snowflake teams because the external table syntax is straightforward.

A typical Canadian properties extract is roughly 19 million rows and around 18 to 22 GB compressed across all provinces. Single-province extracts (Ontario, Quebec, British Columbia, Alberta) sit between 2 and 5 GB.

Step 2: Configure the Snowflake external stage

sql
CREATE OR REPLACE STORAGE INTEGRATION houski_integration
  TYPE = EXTERNAL_STAGE
  STORAGE_PROVIDER = 'S3'
  ENABLED = TRUE
  STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::YOUR_ACCOUNT:role/houski-snowflake-reader'
  STORAGE_ALLOWED_LOCATIONS = ('s3://houski-customer-yourteam/');

CREATE OR REPLACE FILE FORMAT houski_parquet
  TYPE = PARQUET;

CREATE OR REPLACE STAGE houski_stage
  STORAGE_INTEGRATION = houski_integration
  URL = 's3://houski-customer-yourteam/properties/'
  FILE_FORMAT = houski_parquet;

Once that runs, you can list files and preview a row count without copying anything yet.

sql
LIST @houski_stage;
SELECT COUNT(*) FROM @houski_stage/province=ON/;

Step 3: Land the data

The simplest landing is a single wide table that mirrors the schema we publish in our dataset manifest. Snowflake handles schema inference for Parquet, so the initial table is one statement:

sql
CREATE OR REPLACE TABLE raw.houski_properties
USING TEMPLATE (
  SELECT ARRAY_AGG(OBJECT_CONSTRUCT(*))
  FROM TABLE(
    INFER_SCHEMA(
      LOCATION => '@houski_stage/province=ON/',
      FILE_FORMAT => 'houski_parquet'
    )
  )
);

COPY INTO raw.houski_properties
FROM @houski_stage
FILE_FORMAT = (FORMAT_NAME = houski_parquet)
MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE;

For most teams the wide raw table feeds a smaller curated mart with the 30 to 50 columns analysts actually use. Keep the wide raw table around for the columns you do not predict you will need.

Step 4: Schedule incremental refreshes

As part of a custom dataset arrangement, we can deliver a daily delta partition alongside the full snapshot. The delta carries only properties touched in the last 24 hours (new listings, new assessments, new permit filings, automated valuation model (AVM) refreshes). Wire it up with a Snowflake task:

sql
CREATE OR REPLACE TASK refresh_houski_properties
  WAREHOUSE = compute_wh
  SCHEDULE = 'USING CRON 0 9 * * * America/Edmonton'
AS
MERGE INTO raw.houski_properties tgt
USING (
  SELECT * FROM @houski_stage/delta/dt=CURRENT_DATE()/
) src
ON tgt.property_id = src.property_id
WHEN MATCHED THEN UPDATE SET
  tgt.estimate_list_price = src.estimate_list_price,
  tgt.estimate_list_price_date = src.estimate_list_price_date,
  tgt.estimate_rent_monthly = src.estimate_rent_monthly,
  tgt.estimate_rent_monthly_date = src.estimate_rent_monthly_date,
  tgt.assessment_value = src.assessment_value,
  tgt.assessment_year = src.assessment_year,
  tgt.for_sale = src.for_sale,
  tgt.for_sale_list_date = src.for_sale_list_date
WHEN NOT MATCHED THEN INSERT *;

ALTER TASK refresh_houski_properties RESUME;

That gives you a Snowflake table that mirrors Houski's view of the Canadian property market with under 24 hours of lag.

Step 5: The live API path for true real-time

For use cases where 24 hours is too much lag (active acquisition pipelines, fraud detection, mortgage origination), supplement the bulk feed with direct calls to the properties endpoint. Snowflake's external access integration plus a Python user-defined function (UDF) lets a query in your warehouse call Houski directly. You wire up a network rule and a secret, then expose the call as a function:

sql
CREATE OR REPLACE NETWORK RULE houski_api_rule
  MODE = EGRESS
  TYPE = HOST_PORT
  VALUE_LIST = ('api.houski.ca:443');

CREATE OR REPLACE SECRET houski_api_key
  TYPE = GENERIC_STRING
  SECRET_STRING = 'YOUR_HOUSKI_API_KEY';

CREATE OR REPLACE EXTERNAL ACCESS INTEGRATION houski_access
  ALLOWED_NETWORK_RULES = (houski_api_rule)
  ALLOWED_AUTHENTICATION_SECRETS = (houski_api_key)
  ENABLED = TRUE;

CREATE OR REPLACE FUNCTION houski_property(address VARCHAR)
RETURNS VARIANT
LANGUAGE PYTHON
RUNTIME_VERSION = 3.11
HANDLER = 'fetch'
EXTERNAL_ACCESS_INTEGRATIONS = (houski_access)
SECRETS = ('api_key' = houski_api_key)
PACKAGES = ('requests')
AS $$
import _snowflake
import requests

def fetch(address):
    api_key = _snowflake.get_generic_secret_string('api_key')
    r = requests.get(
        'https://api.houski.ca/properties',
        params={'api_key': api_key, 'address': address, 'results_per_page': 1},
        timeout=10,
    )
    return r.json()
$$;

Then any query inside Snowflake can pull a fresh row:

sql
SELECT
  l.application_id,
  houski_property(l.subject_address) AS property
FROM staging.loan_applications l
WHERE l.received_at > DATEADD(hour, -1, CURRENT_TIMESTAMP);

Calls go against your API quota, so most teams batch lookups and cache results back into a Snowflake table for 24 hours.

For the analyst-facing version, data engineers often loop through customer addresses one at a time to backfill or enrich a list before Snowflake ingestion. The underlying call per address looks like the block below, which is a live call against the real API, the request code and the JSON response are generated every time this page is rendered:

API request
TypeScript code
const houski_data = async (): Promise<PropertiesResponse> => {

    // You must copy the PropertiesResponse type declarations from the 
    // Houski API documentation to strongly type the response

    const url = new URL('https://api.houski.ca/properties');
    url.searchParams.set('api_key', 'YOUR_API_KEY');
    url.searchParams.set('city', 'calgary');
    url.searchParams.set('country_abbreviation', 'ca');
    url.searchParams.set('province_abbreviation', 'ab');
    url.searchParams.set('select', 'property_id,latitude,longitude,property_type,interior_sq_m,bedroom,bathroom_full,construction_year,assessment_value,estimate_list_price,estimate_rent_monthly');

    const response = await fetch(url);
    const data = await response.json();

    return data;
}

(async () => {
let data: PropertiesResponse = await houski_data();

// Log the response
console.log(data);
})();
API response
JSON
{
  "cache_hit": false,
  "cost_cents": 4.5,
  "data": [
    {
      "address": "31 Hawkside Park NW",
      "assessment_value": 648500,
      "bathroom_full": 3,
      "bedroom": 3,
      "construction_year": 1988,
      "estimate_list_price": 668241,
      "estimate_rent_monthly": 2587,
      "interior_sq_m": 126.34708404541016,
      "latitude": 51.12946319580078,
      "longitude": -114.17919921875,
      "property_id": "10000f97f5cb7b9f",
      "property_type": "Duplex"
    },
    {
      "address": "6 1744 7 Street SW",
      "bathroom_full": 2,
      "bedroom": 3,
      "construction_year": 2006,
      "estimate_list_price": 660525,
      "estimate_rent_monthly": 3259,
      "interior_sq_m": 125.882568359375,
      "latitude": 51.03611755371094,
      "longitude": -114.0792007446289,
      "property_id": "10004f7afe0c1946",
      "property_type": "House"
    },
    {
      "address": "384 Copperpond Landng SE",
      "bathroom_full": 2,
      "bedroom": 3,
      "construction_year": 2006,
      "estimate_list_price": 389896,
      "estimate_rent_monthly": 2571,
      "interior_sq_m": 125.882568359375,
      "latitude": 50.925743103027344,
      "longitude": -113.92975616455078,
      "property_id": "10007f9761f49940",
      "property_type": "House"
    },
    {
      "address": "239 Dalhurst Way NW",
      "assessment_value": 1110000,
      "bathroom_full": 2,
      "bedroom": 3,
      "construction_year": 1971,
      "estimate_list_price": 1102567,
      "estimate_rent_monthly": 3270,
      "interior_sq_m": 108.46339416503906,
      "latitude": 51.110740661621094,
      "longitude": -114.1513900756836,
      "property_id": "100086f6bc064d3f",
      "property_type": "House"
    },
    {
      "address": "52 Cedargrove Way SW",
      "assessment_value": 662000,
      "bathroom_full": 1,
      "bedroom": 3,
      "construction_year": 1984,
      "estimate_list_price": 635789,
      "estimate_rent_monthly": 2561,
      "interior_sq_m": 136.4734344482422,
      "latitude": 50.95145034790039,
      "longitude": -114.12571716308594,
      "property_id": "1000c277cd905d3b",
      "property_type": "House"
    },
    {
      "address": "28 Sundown Gr SE",
      "assessment_value": 692500,
      "bathroom_full": 3,
      "bedroom": 3,
      "construction_year": 1988,
      "estimate_list_price": 735563,
      "estimate_rent_monthly": 3173,
      "interior_sq_m": 172.61241149902344,
      "latitude": 50.899044036865234,
      "longitude": -114.04895782470705,
      "property_id": "1001109ab2aebbc0",
      "property_type": "House"
    }
  ],
  "error": "",
  "pagination": {
    "current_page": 1,
    "has_next_page": true,
    "has_previous_page": false,
    "page_total": 113490
  },
  "price_quote": false,
  "result_total": 680936,
  "time_ms": 84,
  "ui_info": {
    "city": "Calgary",
    "city_id": "6ec95b53075d062c",
    "city_link": "ca/ab/calgary",
    "city_slug": "calgary",
    "country": "Canada",
    "country_abbreviation": "CA",
    "country_abbreviation_id": "9ace2b6431b7f1be",
    "country_abbreviation_link": "ca",
    "country_slug": "canada",
    "province": "Alberta",
    "province_abbreviation": "AB",
    "province_abbreviation_id": "aae1f05a0f89d2c7",
    "province_abbreviation_link": "ca/ab",
    "province_slug": "alberta"
  }
}

Pipe the output to a JSONL file and PUT it into a Snowflake stage. Done.

Step 6: Modeling on top

Once the raw table is landed, the standard dbt-style modeling stack works without modification. We see customers building these marts most often:

  • dim_properties: one row per property with the curated 30 to 50 attributes
  • fct_listings: one row per historical listing event with price and days on market
  • fct_assessments: one row per assessment year per property
  • dim_demographics: area-level demographic rollups by community (already in the wide table)
  • agg_market_health: city or community level rollups updated daily

A dim_properties view:

sql
CREATE OR REPLACE VIEW marts.dim_properties AS
SELECT
  property_id,
  address,
  city,
  province_abbreviation,
  country_abbreviation,
  latitude,
  longitude,
  property_type,
  construction_year,
  interior_sq_m,
  land_area_sq_m,
  bedroom,
  bathroom_full,
  bathroom_half,
  assessment_value,
  assessment_year,
  estimate_list_price,
  estimate_list_price_date,
  estimate_rent_monthly,
  estimate_rent_monthly_date,
  demographic_income_median_pre_tax,
  demographic_municipal_population,
  score_walkability,
  score_transit,
  for_sale,
  for_sale_list_date
FROM raw.houski_properties;

All the score_* fields in Houski are oriented so that higher is better, on a 0 to 10 scale. A score_walkability of 9 means more walkable than a score of 3. Same goes for transit, flood safety, fire safety, and the other risk scores.

Step 7: Adding the geocoding and AVM endpoints

For workflows that need radius search or batch valuation, the geocoding endpoint and predict endpoint are the same shape: GET request, JSON response, callable from a Snowflake external function or a Lambda you invoke via task.

A common pattern is a Snowflake task that runs nightly, picks every loan in the portfolio with a loan-to-value (LTV) ratio that has drifted more than 5 percent since last refresh, and runs them through /predict to backfill an updated valuation column.

Cost and licensing

Bulk dataset delivery is priced per province and refresh cadence. The live API is metered by the rows and fields returned, with a $99 USD monthly minimum. Most teams that already have Snowflake combine the two: bulk for the long tail of historical analysis, API for the active workflows that need same-day numbers.

The Houski datasets page has current pricing, and the full data dictionary lives in the field documentation. Sample files are available so your data team can validate schema fit before signing anything.

What you skip by buying instead of building

  • Scraping listing portals (legally fraught, technically annoying)
  • Maintaining 13 provincial and territorial assessment authority connections
  • Standardizing addresses across French, English, and abbreviation variants
  • Reconciling property type vocabularies across cities
  • Building and maintaining an AVM

That is roughly two full-time data engineers worth of work, ongoing.

If you want to scope a Snowflake load with us, start at api-documentation/quick-start for the live endpoints, or reach out for a dataset sample. Most teams have data flowing into Snowflake within a week of the first conversation.