Difficulty: Medium · Required tools: Python, optional Ollama · Updated: October 2026
Overview
A price on Etsy has to clear two tests at once. After the fees, it has to leave you more than the wax, the box, the postage, and the hour you spent making the thing. Next to comparable listings, the buyer’s total has to look like it belongs. A model cannot see your invoices, and it is not allowed to browse Etsy for you. The useful split is: a script does the arithmetic, and a small open-weight model on your laptop only explains the sheet the script already printed.
This tutorial builds that sheet. You type your costs. You type the price and shipping of comparable listings you opened yourself. The script returns a break-even item price, a price that hits a profit goal you chose, and where those buyer totals sit against the middle of the comps. If Ollama is running with Llama 3.2, it writes a short note from that JSON. If it is not running, you still have the numbers.
The honest goal: a price you can defend with your own costs and a set of listings you actually looked at. The model does not train on your shop, does not scrape Etsy, and does not get a vote that overrides the floor.

Who This Is Useful For
Use this when you are about to publish one listing and you want the fee math written down before you pick a number.
Skip it if you want a bot that watches competitors every hour, or a model trained on your past sales. Neither of those is this script. Etsy’s own stats, for your shop, live in Shop Manager. This sheet is for a listing you have not published yet.
What You Will Learn
What You Need
Python 3.10 or newer. The script uses only the standard library: csv, json, statistics, and urllib. There is no pip install.
A CSV you will type, named comps.csv. Five listings is the minimum the script accepts. Eight to fifteen, all actually similar to what you are selling, is the useful range. A “soy candle, 8 oz” is not comparable to a “candle-making kit” or a 20 oz three-wick.
Ollama is optional. The model this article uses is llama3.2, the 3B text model, about 2GB on disk. That is a different download from ollama pull llama3, which is the older 8B model and a tight fit on an 8GB laptop. Install from ollama.com, then:
bash
ollama pull llama3.2
Llama 3.2 is open-weight under Meta’s license. You are not training it. You are sending it one JSON object and asking for a short reading.
Build the Pricer
Step 1: Write the costs for one unit, including an hour rate
Put a number on every thing that leaves the shop with that one sale. Materials and the box are obvious. Postage is what you pay the carrier, which is not always what you charge the buyer. Labor is the one people leave at zero, and then the floor looks friendly.
For the sample run, the constants are: materials $8, packaging $1.50, half an hour at $20 (so $10 of labor), postage you pay $4.50, shipping you charge $4.50, and a profit goal of $12. Costs before fees are $24. Yours will differ. Change the constants. Do not leave the sample in place and then trust the price.

Step 2: Type comparable listings into a CSV
Open listings the way a buyer would. For each one, write the item price and the shipping shown for your own country. Skip shops that are a different product, a different size, or a digital file if you are shipping a physical one. Save this as comps.csv next to the script:
csv
price,shipping
18.00,0
22.00,5.00
24.99,0
19.50,4.00
200.00,0
16.00,6.00
27.99,0
22.00,0
15.00,3.00
The $200 row is in the sample on purpose. It should not set your price. The script sets aside buyer totals that sit far outside the middle, then builds the band from what remains.
What the buyer pays is price + shipping. A $16 item with $6 shipping is a $22 purchase. A $22 item with free shipping is also a $22 purchase. Comparing stickers without shipping is how a free-shipping shop looks “expensive” when the buyer pays the same.

Step 3: Use the published US fee schedule, then check it is still the one you pay
The constants match Etsy’s public fee pages as checked for this article in October 2026, for a seller paid in the United States:
TAX_RATE defaults to 0, so the sheet is slightly optimistic: processing really is charged on sales tax the buyer pays and you do not keep. If your buyers cluster in one state, set TAX_RATE to that rate (0.08 for 8%) and the floor rises a little.
Offsite Ads are not in the everyday floor. They apply only when Etsy’s off-site ad is what brought that order. Under $10,000 of sales in the prior 365 days the fee is 15% and you can opt out. At $10,000 it becomes 12%, participation is required, and the fee on one order is capped at $100. The script prints a what-if at 15%. It does not pretend every order pays it.
Sellers outside the United States should replace PROCESSING_RATE and PROCESSING_FIXED from the country table in the Etsy Payments Policy. Canada and Australia domestic orders are not the US line. Some countries also add a regulatory operating fee or VAT on seller fees. If your fee page lists one, add it. Do not assume the US numbers traveled with you.

Listing fee, the 6.5% transaction fee, and how Offsite Ads at 15% or 12% actually work.
Step 4: Run the sheet
Save this as price_etsy.py beside comps.csv and run python3 price_etsy.py. On the sample file it prints a break-even item price of 22.52, a profit-goal item price of 35.78, and a net of 12.00 at that goal. Buyer totals are 27.02 and 40.28, because shipping of 4.50 is added on top. The $200 comp is set aside. The middle of the rest runs from 21.00 to 25.49, median 22.75. Both of your totals sit above that middle. If you do not get these figures, you are not running this sample.
python
#!/usr/bin/env python3
"""Price one listing from your costs and comps you typed yourself.Nothing is scraped. The local model, if it is running, only explains
the sheet this script already calculated.
"""
import csv
import json
import statistics
import urllib.error
import urllib.request
from pathlib import Path
--- your numbers. United States dollars. Change these. ---
MATERIALS = 8.00
PACKAGING = 1.50
LABOR_HOURS = 0.5
HOURLY_RATE = 20.00
POSTAGE_YOU_PAY = 4.50
SHIPPING_YOU_CHARGE = 4.50 # 0 if free shipping is baked into the item price
TARGET_PROFIT = 12.00United States Etsy Payments schedule. Confirm on Etsy's fee pages before you rely on it.
Transaction fee: 6.5% of the displayed item price plus shipping you charge.
Processing: 3% + $0.25 of item price plus shipping. Tax is ignored when TAX_RATE is 0.
LISTING_FEE = 0.20
TRANSACTION_RATE = 0.065
PROCESSING_RATE = 0.03
PROCESSING_FIXED = 0.25
TAX_RATE = 0.0
OFFSITE_ADS_RATE = 0.15 # shown only as a what-if, not in the everyday floorCOMPS = Path("comps.csv") # header: price,shipping
OLLAMA_MODEL = "llama3.2"
REPORT = Path("pricing-report.json")
def money(value):
return round(value + 1e-9, 2)
def your_costs():
return MATERIALS + PACKAGING + LABOR_HOURS * HOURLY_RATE + POSTAGE_YOU_PAY
def keep_rate():
# Share of (item price + shipping charged) left after the percentage fees.
return 1 - TRANSACTION_RATE - PROCESSING_RATE * (1 + TAX_RATE)
def net(item_price, extra_rate=0.0):
base = item_price + SHIPPING_YOU_CHARGE
fees = (
LISTING_FEE
+ (TRANSACTION_RATE + extra_rate) * base
+ PROCESSING_RATE base (1 + TAX_RATE)
+ PROCESSING_FIXED
)
return base - fees - your_costs()
def price_for_profit(profit, extra_rate=0.0):
k = keep_rate() - extra_rate
if k <= 0:
raise SystemExit("Fee rates add up to 100% or more. Check the constants.")
fixed = LISTING_FEE + PROCESSING_FIXED
return (profit + your_costs() + fixed) / k - SHIPPING_YOU_CHARGE
def load_comps(path):
rows = []
with path.open(newline="") as handle:
reader = csv.DictReader(handle)
if not reader.fieldnames or "price" not in reader.fieldnames:
raise SystemExit("comps.csv needs a header row: price,shipping")
for row in reader:
price = float(row["price"])
shipping = float(row.get("shipping") or 0)
rows.append({
"price": money(price),
"shipping": money(shipping),
"buyer_pays": money(price + shipping),
})
if len(rows) < 5:
raise SystemExit("Type at least 5 comparable listings. A median of 2 is not a market.")
return rows
def band(values):
ordered = sorted(values)
if len(ordered) < 4:
return ordered[0], statistics.median(ordered), ordered[-1], ordered, []
q1, _, q3 = statistics.quantiles(ordered, n=4, method="inclusive")
iqr = q3 - q1
low_fence, high_fence = q1 - 1.5 iqr, q3 + 1.5 iqr
kept = [v for v in ordered if low_fence <= v <= high_fence]
dropped = [v for v in ordered if v < low_fence or v > high_fence]
if len(kept) >= 4:
k1, _, k3 = statistics.quantiles(kept, n=4, method="inclusive")
return k1, statistics.median(kept), k3, kept, dropped
return min(kept), statistics.median(kept), max(kept), kept, dropped
def position(buyer_total, low, high):
if buyer_total < low:
return "below"
if buyer_total > high:
return "above"
return "inside"
def explain(summary):
prompt = (
"A seller already calculated this pricing sheet. "
"Do not invent costs, fees, or competitor prices. Use only the JSON. "
"In under 120 words, say whether the break-even total and the profit-goal total "
"sit below, inside, or above the competitor middle, and name the single number "
"in the sheet they would have to change to move a total that sits above the middle "
"back inside it. If both totals are already inside or below, say the sheet does not "
"ask them to cut anything. Do not claim you visited Etsy.\n\n"
+ json.dumps(summary, indent=2)
)
body = json.dumps({
"model": OLLAMA_MODEL,
"stream": False,
"messages": [{"role": "user", "content": prompt}],
}).encode()
request = urllib.request.Request(
"http://127.0.0.1:11434/api/chat",
data=body,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=120) as response:
payload = json.loads(response.read().decode())
except (urllib.error.URLError, TimeoutError) as exc:
return f"Ollama is not running ({exc}). The numbers above are the result. Start it with: ollama pull {OLLAMA_MODEL}"
return payload.get("message", {}).get("content", "").strip() or "The model returned an empty note."
def main():
comps = load_comps(COMPS)
buyer_totals = [row["buyer_pays"] for row in comps]
low, mid, high, kept, dropped = band(buyer_totals)
break_even = price_for_profit(0)
goal = price_for_profit(TARGET_PROFIT)
break_even_total = break_even + SHIPPING_YOU_CHARGE
goal_total = goal + SHIPPING_YOU_CHARGE
summary = {
"currency": "USD",
"your_costs": money(your_costs()),
"shipping_you_charge": money(SHIPPING_YOU_CHARGE),
"fee_schedule": {
"listing_fee": LISTING_FEE,
"transaction_rate": TRANSACTION_RATE,
"processing_rate": PROCESSING_RATE,
"processing_fixed": PROCESSING_FIXED,
"tax_rate_assumed": TAX_RATE,
"note": "United States Etsy Payments. Offsite Ads is not included in the floor.",
},
"break_even_item_price": money(break_even),
"break_even_buyer_pays": money(break_even_total),
"profit_goal": money(TARGET_PROFIT),
"goal_item_price": money(goal),
"goal_buyer_pays": money(goal_total),
"net_at_goal": money(net(goal)),
"net_if_offsite_ads_also_apply_at_goal": money(net(goal, OFFSITE_ADS_RATE)),
"comps_typed": len(comps),
"comps_in_middle": len(kept),
"outlier_buyer_totals_set_aside": [money(v) for v in dropped],
"competitor_middle_buyer_pays": {
"low": money(low),
"median": money(mid),
"high": money(high),
},
"break_even_vs_middle": position(break_even_total, low, high),
"goal_vs_middle": position(goal_total, low, high),
}
REPORT.write_text(json.dumps(summary, indent=2) + "\n")
print(json.dumps(summary, indent=2))
print()
print(explain(summary))
if __name__ == "__main__":
main()
The same object is written to pricing-report.json. That file is the record. The paragraph underneath it is optional.
The keep rate in the sample is 1 - 0.065 - 0.03 = 0.905. You keep about 90.5 cents of each dollar of item price plus shipping you charge, then you still pay the $0.20 listing fee, the $0.25 processing flat fee, and your $24 of costs. Solving that for $12 of profit is what produces $35.78. It is not a model guess. If net_at_goal is not your TARGET_PROFIT within a cent, the constants and the formula have drifted apart. Fix that before you read any prose.
Step 5: Read “below”, “inside”, and “above” before you touch the listing
Compare buyer totals with buyer totals. In the sample, the middle of the comps is a buyer total of about $21 to $25. Your break-even total is $27.02. Your profit-goal total is $40.28. Both are above the middle. Listing at $18 because several comps say 18 would not cover the $24 of costs plus fees. The sheet is telling you the comps, as typed, do not pay for this product at a $12 profit, and do not even pay for it at zero profit once labor is included.
That is a useful answer. It is also allowed to mean the comps are the wrong comps: smaller candles, supplies instead of a finished good, a different country. Replace the CSV and run again. Do not ask the model to “find a sweeter price.”

| Reading | What it means for the listing |
|---|---|
| Break-even above the middle | These comps do not cover your costs. Change the product, the costs, or the comp set. |
| Break-even inside, goal above | You can list without a loss and still miss the profit you wrote down. Decide which one you are pricing for. |
| Goal inside the middle | The profit you asked for already sits among these listings. You do not need a discount to “match the market.” |
| Goal below the middle | You would be cheaper than most of this set even at your goal. Going lower is a choice, not a requirement. |
Step 6: Let the local model explain the sheet, and nothing else
explain posts the JSON to http://127.0.0.1:11434/api/chat with stream off. The prompt forbids inventing costs, fees, or comps, caps the note at about 120 words, and tells the model not to claim it visited Etsy. The reply is message.content. Prices never go to a cloud API. They go to a process on your own machine, and only if that process is already running.
If the note disagrees with break_even_vs_middle or goal_vs_middle, keep the JSON. Those two fields are comparisons of numbers you can recompute. The paragraph is not a second calculation.
The 3B open-weight model this script calls locally. About 2GB. It explains the sheet. It does not price the listing.
Step 7: Re-run when a cost changes, not on a timer
Postage rates, a new supplier, and a profit goal you no longer believe are reasons to run the script again. A week passing is not. Etsy does not reprice your comps on a schedule this file knows about. You reprice the CSV when you have looked at new listings.
Keep pricing-report.json with the listing draft. When you change TARGET_PROFIT or the hourly rate, the previous file is how you remember what you believed last time.
Common Mistakes to Avoid
Asking a model for “the optimal price.” Optimal is not a number a language model can see. The old shape of this tutorial pasted five prices into a prompt and treated the JSON that came back as a recommendation. Those prices were not a market, the prompt did not contain your postage, and nothing was trained. The script here will refuse to invent a third price between the floor and the comps. If the goal sits above the middle, the answer is that the goal sits above the middle.
Scraping, or comparing stickers to buyer totals. Collecting listings by hand is slower. It is also the method that stays inside Etsy’s terms and that forces you to notice shipping. A shop at $16 with $6 shipping is not undercutting a shop at $22 with free shipping. And a scraper that adds time.sleep to “avoid a block” is a scraper with a nap. Do not build it.
Leaving labor at zero, then calling the comps expensive. With labor set to $0 in this sample, costs fall by $10 and the floor drops with them. The comps did not get cheaper. You stopped paying yourself. That can be a real decision for a first batch. It should show up as a decision in the constants, not as a belief that $18 is what the product costs.

Going Further
Your country’s processing line. The US line is 3% + $0.25. The Etsy Payments Policy lists a different percent and flat fee by bank-account country, and a higher percent on some international orders. Swap the two constants. Leave the 6.5% transaction fee unless Etsy’s fee page has changed it.
A currency that does not match your bank. If you list in a currency other than your payment-account currency, Etsy adds a 2.5% currency conversion fee on the sale amount. That is another extra_rate you can pass through price_for_profit the same way Offsite Ads is passed through net. Do not stack it unless you are actually listing in the other currency.
A registered Etsy app, later. If you outgrow a CSV, the supported route is a registered app and the Open API, under the API terms: no mimicking checkout, no long-term copies of listing content, no scraping as a fallback when a key is annoying to get. This article stops at the CSV because that path needs no key and does not pretend to be a live feed.
Key Takeaways
Sources