Review · updated OCT 11

Qwen3.6 27B review: one of the best local models we've tested

It scored 97 out of 100, #1 of 56. It solved 29 of 30 coding jobs and scored 97 on reading documents. Runs on a 24 GB graphics card or a Mac with 32 GB.

The short version
  • Qwen3.6 27B is a free model from Alibaba's Qwen team that you can run on your own computer. In our tests it's one of the best local models we've tested: 97 out of 100, #1 of 56.
  • It solved 29 of 30 coding jobs and scored 97 on reading documents. On our hardest tasks it scored 91.
  • Runs on a 24 GB graphics card or a Mac with 32 GB.

Coding

Our coding test is 30 programming jobs, from small ones like reading time durations or cleaning up messy data to harder ones like a config-file parser or a double-entry ledger. We run each answer against tests the model never sees, and a job only counts if everything passes. Qwen3.6 27B got 29 of 30 right. That's as good as any model we've tested.

Reading documents

The second test hands the model things like an expense claim thread, a pay stub or an insurance statement, and asks for specific numbers and dates. Many questions need a bit of math, or noticing a correction further down the email. Qwen3.6 27B scored 97; the best model scored 100.

TestScorePublic questionsSecret questions
Coding9710096
Reading documents979298
Decisions9910099

On the 18 hardest tasks (included in the scores above) it scored 91. This number separates the top models.

We tested the full-size version online. The smaller versions you download usually score a little lower.

Can your computer run it?

You can download Qwen3.6 27B in several sizes. Smaller versions fit on more computers and lose a little quality, so pick the biggest one that fits.

Your computerRuns it?Version to download
Graphics card, 8 GBNo–
Graphics card, 12 GBNo–
Graphics card, 16 GBNo–
Graphics card, 24 GBYesQ5
Graphics card, 32 GBYesQ6
Mac, 16 GBNo–
Mac, 24 GBNo–
Mac, 32 GBYesQ4
Mac, 48 GBYesQ8
Mac, 64 GBYesQ8
Mac, 96 GBYesQ8
Mac, 128 GBYesQ8

For normal-length chats. Long documents need more memory. Q4 or Q8? · How we worked this out

How to try it

Search for "Qwen3.6 27B GGUF" in LM Studio or on Hugging Face and pick the version that fits your computer. New to this? Start with our Ollama guide.

Its actual answers

What Qwen3.6 27B wrote for our public questions, word for word.

Coding: INI parser (hard) Worked
```python
import re

def parse_ini(text: str) -> dict[str, dict[str, str]]:
    sections: dict[str, dict[str, str]] = {}
    current_section = None
    last_key = None

    for line in text.splitlines():
        stripped = line.strip()

        # Rule 3: Ignore empty or comment lines
        if not stripped or stripped[0] in (';', '#'):
            last_key = None
            continue

        # Rule 6: Continuation lines
        if line[0] in (' ', '\t'):
            if last_key is not None and current_section is not None:
                sections[current_section][last_key] += '\n' + stripped
                continue
            raise ValueError("Malformed continuation line without preceding key")

        # Rule 1: Section headers
        section_match = re.match(r'^\s*\[\s*([^\]]+)\s*\]\s*$', line)
        if section_match:
            current_section = section_match.group(1)
            if current_section not in sections:
                sections[current_section] = {}
            last_key = None
            continue

        # Rule 10: Key before any section
        if current_section is None:
            raise ValueError("Key line before any section header")

        # Rule 2: Key-value splitting
        eq_pos = line.find('=')
        colon_pos = line.find(':')
        if eq_pos == -1 and colon_pos == -1:
            raise ValueError(f"Malformed line: {line!r}")

        split_pos = eq_pos if eq_pos != -1 and (colon_pos == -1 or eq_pos < colon_pos) else colon_pos
        key = line[:split_pos].strip().lower()
        if not key:
            raise ValueError(f"Empty key in line: {line!r}")

        raw_val = line[split_pos + 1:]

        # Rule 4 & 5: Quotes and inline comments
        lstrip_val = raw_val.lstrip()
        if lstrip_val.startswith('"'):
            close_idx = lstrip_val.find('"', 1)
            if close_idx == -1:
                raise ValueError(f"Unclosed quote in: {line!r}")
            value = lstrip_val[1:close_idx]
        else:
            cm = re.search(r'[ \t]+[;#]', raw_val)
            c_pos = cm.start() if cm else -1
            value = raw_val[:c_pos] if c_pos != -1 else raw_val
            value = value.strip()

        sections[current_section][key] = value
        last_key = key

    # Rule 8: DEFAULT section inheritance
    if 'DEFAULT' in sections:
        defaults = sections['DEFAULT']
        for sec in sections:
            if sec == 'DEFAULT':
                continue
            for k, v in defaults.items():
                if k not in sections[sec]:
                    sections[sec][k] = v

    # Rule 9: Interpolation resolution
    _RE_INTERP = re.compile(r'\$\{([^}]+)\}')

    def resolve(val: str, sec: str, visiting: set[tuple[str, str]]) -> str:
        def replacer(m: re.Match) -> str:
            ref = m.group(1)
            if ':' in ref:
                ref_sec, ref_key = ref.split(':', 1)
                ref_key = ref_key.strip().lower()
                ref_sec = ref_sec.strip()
            else:
                ref_sec = sec
                ref_key = ref.strip().lower()

            if ref_sec not in sections or ref_key not in sections[ref_sec]:
                raise KeyError(f"Missing reference: {ref_sec}:{ref_key}")
            if (ref_sec, ref_key) in visiting:
                raise ValueError("Interpolation cycle detected")

            visiting.add((ref_sec, ref_key))
            try:
                return resolve(sections[ref_sec][ref_key], ref_sec, visiting)
            finally:
                visiting.remove((ref_sec, ref_key))

        return _RE_INTERP.sub(replacer, val)

    resolved_sections: dict[str, dict[str, str]] = {}
    for sec in sections:
        resolved_sections[sec] = {}
        for k in sections[sec]:
            resolved_sections[sec][k] = resolve(sections[sec][k], sec, set())

    # Rule 8: DEFAULT is not returned
    resolved_sections.pop('DEFAULT', None)
    return resolved_sections
```
Coding: TTL LRU (hard) Worked
```python
from collections import OrderedDict

class TTLCache:
    def __init__(self, capacity: int, ttl: float, clock):
        self.capacity = capacity
        self.ttl = ttl
        self.clock = clock
        self.cache = OrderedDict()
        self.hits = 0
        self.misses = 0
        self.evictions = 0
        self.expirations = 0

    def _remove_expired(self):
        now = self.clock()
        expired_keys = [k for k, (_, exp) in self.cache.items() if now >= exp]
        for k in expired_keys:
            self.cache.pop(k)
            self.expirations += 1

    def put(self, key, value):
        if self.capacity == 0:
            return

        now = self.clock()
        if key in self.cache:
            self.cache[key] = (value, now + self.ttl)
            self.cache.move_to_end(key)
        else:
            if len(self.cache) >= self.capacity:
                self._remove_expired()
                if len(self.cache) >= self.capacity:
                    self.cache.popitem(last=False)
                    self.evictions += 1
            
            self.cache[key] = (value, now + self.ttl)
            self.cache.move_to_end(key)

    def get(self, key, default=None):
        if self.capacity == 0:
            self.misses += 1
            return default

        now = self.clock()
        if key in self.cache:
            value, expiry = self.cache[key]
            if now >= expiry:
                del self.cache[key]
                self.expirations += 1
                self.misses += 1
                return default
            
            self.cache.move_to_end(key)
            self.hits += 1
            return value
        else:
            self.misses += 1
            return default

    def __len__(self):
        if self.capacity == 0:
            return 0
        now = self.clock()
        return sum(1 for _, (_, exp) in self.cache.items() if now < exp)

    def stats(self):
        return {
            "hits": self.hits,
            "misses": self.misses,
            "evictions": self.evictions,
            "expirations": self.expirations
        }
```
Coding: Parse duration Worked
```python
import re

def parse_duration(s: str) -> int:
    pattern = r'\s*(?:([0-9]+)\s*h)?\s*(?:([0-9]+)\s*m)?\s*(?:([0-9]+)\s*s)?\s*'
    match = re.fullmatch(pattern, s, re.IGNORECASE)
    if not match:
        raise ValueError("Invalid duration string")
    h, m, sec = match.groups()
    if h is None and m is None and sec is None:
        raise ValueError("Empty or blank string")
    total = 0
    if h is not None:
        total += int(h) * 3600
    if m is not None:
        total += int(m) * 60
    if sec is not None:
        total += int(sec)
    return total
```
Coding: Summarize ranges Worked
```python
def summarize_ranges(nums: list[int]) -> str:
    if not nums:
        return ""
        
    sorted_nums = sorted(set(nums))
    result = []
    i = 0
    n = len(sorted_nums)
    
    while i < n:
        start = sorted_nums[i]
        end = start
        while i + 1 < n and sorted_nums[i + 1] == end + 1:
            i += 1
            end = sorted_nums[i]
            
        if start == end:
            result.append(str(start))
        elif end == start + 1:
            result.append(str(start))
            result.append(str(end))
        else:
            result.append(f"{start}..{end}")
            
        i += 1
        
    return ",".join(result)
```
Coding: Top customers Worked
```python
from collections import defaultdict

def top_customers(rows: list[dict], n: int) -> list[tuple[str, float]]:
    totals = defaultdict(float)
    first_names = {}
    
    for row in rows:
        name = row.get("customer")
        if not isinstance(name, str):
            continue
        name = name.strip()
        if not name:
            continue
            
        amount_str = row.get("amount")
        if not isinstance(amount_str, str):
            continue
        try:
            cleaned = amount_str.strip().replace('$', '').replace(',', '')
            amount = float(cleaned)
        except (ValueError, TypeError):
            continue
            
        key = name.lower()
        if key not in first_names:
            first_names[key] = name
        totals[key] += amount
        
    customers = [(first_names[k], round(t, 2)) for k, t in totals.items()]
    customers.sort(key=lambda x: (-x[1], x[0]))
    return customers[:n]
```
Coding: Slugify Worked
```python
import re
import unicodedata

def slugify(title: str, max_len: int = 50) -> str:
    # 1. Transliterate accented chars to ASCII and drop non-ASCII
    text = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore').decode('ascii')
    # 2. Lowercase
    text = text.lower()
    # 3. Replace non-alphanumeric runs with "-" and strip leading/trailing "-"
    text = re.sub(r'[^a-z0-9]+', '-', text).strip('-')
    
    # 4 & 5. Truncate to max_len without cutting words, ensure no trailing "-"
    if len(text) <= max_len:
        return text

    words = text.split('-')
    if len(words[0]) > max_len:
        return words[0][:max_len]

    parts = [words[0]]
    current_len = len(words[0])
    for word in words[1:]:
        if current_len + 1 + len(word) <= max_len:
            parts.append(word)
            current_len += 1 + len(word)
        else:
            break
            
    return '-'.join(parts)
```
Coding: Token bucket Worked
```python
class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float, clock):
        self._capacity = capacity
        self._refill_rate = refill_per_sec
        self._clock = clock
        self._tokens = capacity
        self._last_time = self._clock()

    def _refill(self):
        now = self._clock()
        elapsed = now - self._last_time
        if elapsed > 0:
            # Add tokens proportional to elapsed time
            added = elapsed * self._refill_rate
            # Ensure tokens do not exceed capacity
            self._tokens = min(self._capacity, self._tokens + added)
            self._last_time = now

    def allow(self, cost: float = 1.0) -> bool:
        self._refill()
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False

    @property
    def tokens(self) -> float:
        self._refill()
        return self._tokens
```
Decisions: Refund window (hard) 100% right
{"outcome": {"full_refund": 0.0, "partial_refund": 0.0, "store_credit": 1.0, "nothing": 0.0}, "defective": {"true": 0.0, "false": 1.0}}
Decisions: Incident matrix (hard) 100% right
{"severity":{"0":0.0,"1":1.0,"2":0.0,"3":0.0},"page":{"true":0.0,"false":1.0}}
Decisions: Tool followup (hard) 100% right
{
  "tool": {
    "web_search": 0.0,
    "calculator": 0.0,
    "calendar": 1.0,
    "email": 0.0,
    "none": 0.0
  },
  "confirm": {
    "true": 1.0,
    "false": 0.0
  }
}
Decisions: Legit security alert (hard) 100% right
{"phishing": {"true": 0.01, "false": 0.99}, "action_needed": {"true": 0.01, "false": 0.99}}
Decisions: Meeting slot (hard) 100% right
{
  "slot": {
    "A": 0.0,
    "B": 1.0,
    "C": 0.0,
    "D": 0.0
  },
  "raj_last": {
    "true": 1.0,
    "false": 0.0
  }
}
Decisions: Review mixed (hard) 100% right
{
  "hardware": {
    "true": 0.99,
    "false": 0.01
  },
  "support": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Support checkout down 100% right
{
  "department": {
    "billing": 0.03,
    "technical": 0.96,
    "account": 0.003,
    "shipping": 0.004,
    "sales": 0.003
  },
  "urgency": {
    "0": 0.001,
    "1": 0.001,
    "2": 0.008,
    "3": 0.99
  },
  "outage": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Refund wrong plan 100% right
{"department":{"billing":0.96,"technical":0.01,"account":0.01,"shipping":0.01,"sales":0.01},"refund":{"true":0.99,"false":0.01},"tone":{"calm":0.98,"frustrated":0.02}}
Decisions: Moderation doxxing 100% right
{
  "policy": {
    "none": 0.0,
    "harassment": 1.0,
    "hate": 0.0,
    "spam": 0.0,
    "self_harm": 0.0
  },
  "personal_info": {
    "true": 1.0,
    "false": 0.0
  }
}
Decisions: Route calendar 100% right
{
  "tool": {
    "web_search": 0.0,
    "calculator": 0.0,
    "calendar": 1.0,
    "email": 0.0,
    "none": 0.0
  },
  "confirm": {
    "true": 1.0,
    "false": 0.0
  }
}
Decisions: Doc invoice missing due 100% right
{"doc_type":{"invoice":1.0,"resume":0.0,"contract":0.0,"bank_statement":0.0,"other":0.0},"missing_due_date":{"true":1.0,"false":0.0}}
Decisions: Phishing paypal 100% right
{ "phishing": { "true": 0.99, "false": 0.01 }, "risk": { "0": 0.0, "1": 0.0, "2": 0.01, "3": 0.99 } }
Decisions: Pii ssn email 100% right
{"data_kind":{"none":0.0,"contact":0.01,"financial":0.0,"government_id":0.99,"health":0.0},"sensitive":{"true":1.0,"false":0.0}}
Decisions: Review mixed 100% right
{
  "sentiment": {
    "positive": 0.05,
    "neutral": 0.05,
    "negative": 0.90
  },
  "defect": {
    "true": 0.98,
    "false": 0.02
  },
  "recommend": {
    "true": 0.02,
    "false": 0.98
  }
}
Documents: Saas escalator (hard) 70% right
{
  "year2_price_per_seat_month": 47.25,
  "year3_price_per_seat_month": 47.25,
  "year1_invoice": 58320.00,
  "year2_invoice": 99996.00,
  "addon_months_billed": 6,
  "addon_invoice": 38760.00,
  "year3_invoice": 134946.00,
  "year3_discount_percent": 15,
  "total_contract_value": 293262.00,
  "contract_end_date": "2027-02-28"
}
Documents: Expense thread 100% right
{
  "employee_id": "EMP-20417",
  "destination_city": "Lisbon",
  "trip_start": "2025-02-24",
  "trip_end": "2025-02-27",
  "approved_items": [
    { "date": "2025-02-24", "category": "airfare", "amount_usd": 1184.60 },
    { "date": "2025-02-24", "category": "ground_transport", "amount_usd": 38.88 },
    { "date": "2025-02-25", "category": "meals", "amount_usd": 229.39 },
    { "date": "2025-02-26", "category": "lodging", "amount_usd": 466.56 },
    { "date": "2025-02-27", "category": "ground_transport", "amount_usd": 44.82 }
  ],
  "rejected_item_count": 1,
  "per_diem_days": 3,
  "per_diem_usd": 195.00,
  "total_reimbursable_usd": 2159.25,
  "approver_email": "priya.raman@corvane.com"
}
Documents: Lease amendment 100% right
{
  "tenants": ["Marcus Lin", "Sofia Lin"],
  "landlord": "Ridgeline Property Group LLC",
  "zip": "97205",
  "lease_end": "2025-11-30",
  "original_monthly_rent": 2150.00,
  "monthly_rent_from_2025_06_01": 2236.00,
  "late_fee_from_2025_06_01": 111.80,
  "security_deposit": 2150.00,
  "total_pet_deposits": 800.00,
  "total_monthly_payment_july_2025": 2306.00,
  "move_in_payment": 4700.00
}
Documents: Ticket SLA 92% right
{
  "ticket_id": "48213",
  "account_id": "ACC-7731",
  "open_issue": "inventory_sync",
  "resolved_issues": ["billing_address", "invoice_pdf"],
  "affected_orders": ["SO-99812", "SO-99820", "SO-99827"],
  "priority": "P2",
  "sla_due_local": "2025-09-15T15:30",
  "sla_due_utc": "2025-09-15T20:30:00Z",
  "reissued_invoice": "INV-2025-0812"
}
Documents: Sales footnotes 100% right
{
  "q3_total_usd": 15346000,
  "q2_total_usd": 14464000,
  "q2_central_originally_reported_usd": 3047000,
  "q2_to_q3_change_pct": 6.1,
  "top_region_q3": "East",
  "fastest_growing_region_q1_to_q3": "International",
  "regions_declining_q2_to_q3": ["East"],
  "international_q3_organic_usd": 1731000,
  "west_excluding_mountain_q3_usd": 4201000
}

Size: 28B parameters. First tested OCT 10.

Models that scored about the same

Comments

Sign in with GitHub to comment. Spam and abuse are hidden automatically.