What does a 2-bedroom condo in Singapore earn in rent against what it costs to buy, and how much does that change across the island? Listing portals show asking prices and asking rents side by side, so the question looks like one search away. It is two searches per district and one trap.

I used the 99.co API from Python to pull one page of 50 two-bedroom condo listings for sale and one for rent in each of 10 districts, plus the 40 latest transactions per district, on 3 November 2026. The short answer: gross yields on asking prices run from 2.69% in District 9 (Orchard, River Valley) to 4.36% in District 27 (Yishun, Sembawang). But only after dropping the 19.4% of sale listings that are in buildings not finished yet.

The data

The 99.co API returns 99.co search results as JSON. One call to /search-property with listing_type, a district id in query_ids, main_category=condo and rooms=2,2 gives up to 50 listings with price, floorAreaSqft, builtYear, neighbourhood and the nearest MRT station. /transactions returns recorded deals for the same district, 20 per page, with price, area, profit and holding period.

The 10 districts mix the core and the outskirts: 1, 3, 5, 9, 10, 15, 16, 19, 22 and 27. That made 20 search calls and 20 transaction calls, plus 2 more to test the code below. Every sale and rent row was a condo with a price and a floor area. The completed-unit yields below do drop sale listings, though: the unbuilt ones and the 22 of 500 that have no builtYear at all. That leaves 381 completed sale listings and some small bases: District 22 rests on 27 sale listings and District 1 on only 14.

The code

This is the whole calculation for one district, District 19 (Serangoon, Hougang, Punggol). It runs as printed with a RapidAPI key in RAPIDAPI_KEY.

import json import os import statistics import requests HOST = "99-co-sg-api.p.rapidapi.com" # 99.co API on RapidAPI BASE = os.environ.get("API_BASE", "https://" + HOST) def headers(): if "API_BASE" in os.environ: return json.loads(os.environ.get("API_HEADERS", "{}")) return {"x-rapidapi-host": HOST, "x-rapidapi-key": os.environ["RAPIDAPI_KEY"]} def listings(listing_type, district): """One page of 50 two-bedroom condo listings in a district, for sale or for rent.""" params = { "listing_type": listing_type, "query_ids": district, # dtdistrict01 ... dtdistrict28 "query_type": "district", "main_category": "condo", "rooms": "2,2", # min,max: exactly two bedrooms (2+1 included) "page_size": 50, "page_num": 1, } r = requests.get(BASE + "/search-property", params=params, headers=headers(), timeout=60) r.raise_for_status() return r.json()["data"]["results"]["listings"] def median_psf(rows): return statistics.median(x["price"] / x["floorAreaSqft"] for x in rows) district = "dtdistrict19" # Serangoon, Hougang, Punggol sale = listings("sale", district) rent = listings("rent", district) # Leave out units in buildings that are not finished: they are priced as new launches completed = [x for x in sale if str(x.get("builtYear", "")).isdigit() and int(x["builtYear"]) <= 2026] sale_psf = median_psf(completed) # S$ per sq ft, asking price rent_psf = median_psf(rent) # S$ per sq ft per month print("sale listings: %d (%d completed)" % (len(sale), len(completed))) print("rent listings: %d" % len(rent)) print("asking price: S$ %.0f psf" % sale_psf) print("asking rent: S$ %.2f psf a month" % rent_psf) print("gross yield: %.2f%%" % (rent_psf * 12 / sale_psf * 100)) 

My run printed 50 sale listings (46 completed), 50 rentals, S$ 1,784 psf asking, S$ 5.00 psf a month in rent, and a gross yield of 3.37%.

I compare price per square foot rather than whole prices because the flats are not the same size. In District 9 the median 2-bedroom for rent was 904 sq ft and the median one for sale 791 sq ft. Dividing two medians of different-sized flats would bias the yield; dividing rent per sq ft by price per sq ft does not.

Gross yields by district

2-bed condos yield 4.4% in Yishun and Sembawang, 2.7% in Orchard

District Yield, completed units Yield, all listings Sale listings not built Asking price psf (completed) Rent psf a month
D27 Yishun, Sembawang 4.36% 4.36% 0% S$ 1,371 S$ 4.98
D22 Jurong 4.30% 3.46% 26% S$ 1,768 S$ 6.33
D1 Raffles Place, Marina 3.82% 3.02% 68% S$ 2,190 S$ 6.97
D5 Clementi, West Coast 3.79% 3.70% 22% S$ 2,022 S$ 6.38
D16 Bedok 3.65% 3.42% 22% S$ 1,581 S$ 4.81
D19 Serangoon, Hougang 3.37% 3.35% 8% S$ 1,784 S$ 5.00
D3 Queenstown, Tiong Bahru 3.35% 3.26% 18% S$ 2,475 S$ 6.91
D15 Katong, Marine Parade 3.07% 2.83% 12% S$ 2,323 S$ 5.95
D10 Bukit Timah, Tanglin 2.85% 2.84% 6% S$ 2,618 S$ 6.22
D9 Orchard, River Valley 2.69% 2.66% 12% S$ 2,654 S$ 5.95

The pattern is the familiar one, measured. Prime districts cost the most per square foot to buy, but their rents do not keep up with prices: District 9 asks S$ 2,654 psf against S$ 5.95 a month, District 27 asks S$ 1,371 against S$ 4.98. The cheapest district to buy into has the best yield, and the most expensive has the worst.

The median of the 10 completed-unit yields is 3.51%. These are gross yields on asking prices. They leave out maintenance fees, property tax, agent fees and vacancy, and actual sale prices and signed rents can differ from what is asked. Use them to rank districts, not to model a purchase.

District 27 and District 1 also have the two thinnest rental markets of the ten: 37 and 35 two-bedroom condo rentals listed in total, against 296 in District 9. Their yields rest on all of them, so these are full counts rather than samples, but small ones.

The trap: new launches on the sale side

In D1, two in three 2-bed condos for sale are not built yet

99.co lists developer units in projects still under construction next to resale flats, and each listing carries its builtYear. Across the 500 sale listings, 19.4% had a builtYear after 2026. Among the 472 rentals it was 0.6%, which makes sense: you cannot rent out a flat that does not exist yet.

So a naive yield divides the rent of older, finished flats by a sale price that partly reflects brand-new launches. In District 1, 68% of the 2-bedroom sale listings were in unbuilt projects, and the median sale listing had a builtYear of 2029 while the median rental was from 2012. The naive yield there is 3.02%. With completed units only it is 3.82%. District 22 moves from 3.46% to 4.30%. Districts with few launches, such as 10 and 27, barely move.

If you are comparing yields, filter on builtYear before you take any median.

What resales actually made

/transactions adds the other side of the market. The 400 transactions (40 per district, the latest available, all recorded in September and October 2026) were 275 resales, 116 new sales from developers and 9 sub sales, where a buyer sells on before the building is completed. They cover all bedroom counts, not only 2-bedroom units.

256 of the resales came with both a profit and a holding_period. The median seller held for 9.1 years and made S$ 457,900, a gain of 31.2% on the purchase price. 16 of the 256 sold at a loss.

Longer holds earn more in total, not more per year

Split by holding period, the total gain rises with time, from a median of 12.5% for holds of 3 to 5 years to 59.0% for holds of 10 years or more. Per year it hardly moves: a median of 3.1%, 3.4% and 3.0% a year across the three groups.

None of the 256 was held for less than 3 years, and the shortest hold was exactly 3.0 years. That is most likely the shape of a rule, not of the market: Singapore charges seller's stamp duty on homes sold within the holding period, and the period is longer for purchases made from 4 July 2025.

Pitfalls in the 99.co data

psf is a string, and on rentals it is monthly. It comes back as text, such as "S$ 8.36" on a rental, where it means rent per sq ft per month; on a sale it is the asking price per sq ft. Both matched price / floorAreaSqft on every row I pulled, so compute it yourself from the numbers and keep the sale and rent figures in separately named columns.

beds is a string. Every row in my searches had "2", but compare it as text or convert it first.

rooms=2,2 includes 2+1 units. 52 of the 972 listings had titles starting "2+1 Bed", flats with a study. If the study matters to your analysis, read it from the title.

postedAt in search results is relative. It reads "3d" or "1h", not a date. Record when you collected the data, or fetch the full listing for an exact time.

Transaction numbers are text. price is "S$ 4.737M", psf_psm is "S$ 3,098", area is "1,529" and profit is "+S$ 400,820" or "-". 19 of the 275 resales had no profit or holding period. Parse carefully and drop the dashes rather than reading them as zero.

Unit numbers are masked. 396 of the 400 transactions show the floor only, as in "06-xx", so you can match a deal to a building and a floor, not to a unit.

Photo URLs carry the agent's name and number. The listing photos are watermarked, and the watermark text sits in the image URL. If you store raw responses, drop the photos and agent fields first.

Where to go from here

The 99.co API is available on RapidAPI, with a Postman collection to try the calls before writing code and Python and JavaScript examples in the GitHub repo. You can also use the 99.co Scraper on Apify to extract property listings and related data. For an overview of what else it returns (new launches, project details, price trends and the agent directory), see the 99.co API overview on LinkedIn.

The obvious next step is to run the same yield for every district and bedroom count, and to compare asking rents with the rents in /transactions?type=rent. If you would rather have the Singapore data as a file than make the calls, we can build that dataset for you.