Review · updated OCT 11

Granite 4.2 8B review: decent, with trade-offs

It scored 77 out of 100, #15 of 56. It solved 23 of 30 coding jobs and scored 78 on reading documents. Runs on an 8 GB graphics card or a Mac with 16 GB.

The short version
  • Granite 4.2 8B is a free model from IBM that you can run on your own computer. In our tests it's decent, with trade-offs: 77 out of 100, #15 of 56.
  • It solved 23 of 30 coding jobs and scored 78 on reading documents. On our hardest tasks it scored 55.
  • Runs on an 8 GB graphics card or a Mac with 16 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. Granite 4.2 8B got 23 of 30 right. The best local coders solved 29 of 30.

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. Granite 4.2 8B scored 78; the best model scored 100.

TestScorePublic questionsSecret questions
Coding778674
Reading documents787180
Decisions939692

On the 18 hardest tasks (included in the scores above) it scored 55. 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 Granite 4.2 8B 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 GBYesQ4
Graphics card, 12 GBYesQ8
Graphics card, 16 GBYesQ8
Graphics card, 24 GBYesQ8
Graphics card, 32 GBYesQ8
Mac, 16 GBYesQ6
Mac, 24 GBYesQ8
Mac, 32 GBYesQ8
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 "Granite 4.2 8B GGUF" in LM Studio or on Hugging Face and pick the version that fits your computer. New to this? Start with our Ollama guide.

Want the strongest all-rounder instead? Qwen3.6 27B scored 97.

Its actual answers

What Granite 4.2 8B wrote for our public questions, word for word.

Coding: INI parser (hard) Didn't work
```python
import re
from typing import Dict, Any

def parse_ini(text: str) -> dict[str, dict[str, str]]:
    lines = text.splitlines()

    raw_sections: Dict[str, Dict[str, str]] = {}
    current_section = None
    pending_key = None
    pending_value = None

    def strip_comment(line: str) -> str:
        """Remove inline comment if present, respecting quotes."""
        inside_quote = False
        for i, ch in enumerate(line):
            if ch == '"':
                if not inside_quote:
                    inside_quote = True
                else:
                    inside_quote = False
            elif ch in ';#' and not inside_quote:
                if i > 0 and line[i-1].isspace():
                    return line[:i]          # cut before the comment marker
        return line

    for raw_line in lines:
        # Rule 3: ignore empty or comment-start lines
        stripped_raw = raw_line.lstrip()
        if not stripped_raw or stripped_raw[0] in ';#':
            continue

        clean_line = strip_comment(raw_line)

        # Check for section header first
        if clean_line.startswith('[') and clean_line.endswith(']'):
            # finalize pending key if any
            if pending_key is not None and current_section is not None:
                if current_section not in raw_sections:
                    raw_sections[current_section] = {}
                raw_sections[current_section][pending_key] = pending_value
                pending_key = None
                pending_value = None
            # section name (do not trim inside)
            name = clean_line[1:-1]
            current_section = name
            if current_section not in raw_sections:
                raw_sections[current_section] = {}
            continue

        # Check for continuation line
        if pending_key is not None and current_section is not None and clean_line.startswith((' ', '\t')):
            # continuation: append stripped text with newline
            stripped_text = clean_line.strip()
            pending_value += "\n" + stripped_text
            continue

        # Otherwise, key line (must be inside a section)
        if current_section is None:
            # key line before any section -> ValueError
            raise ValueError("Key line before any section header")

        # Find first '=' or ':' outside quotes
        inside_quote = False
        found_sep = None
        sep_char = None
        for i, ch in enumerate(clean_line):
            if ch == '"':
                if not inside_quote:
                    inside_quote = True
                else:
                    inside_quote = False
            elif ch in '=:' and not inside_quote:
                if found_sep is None:
                    found_sep = i
                    sep_char = ch

        if found_sep is None:
            # malformed key line
            raise ValueError("Malformed key line: missing separator outside quotes")

        key_part = clean_line[:found_sep]
        value_part = clean_line[found_sep+1:]

        key_raw = key_part.strip()
        if not key_raw:
            raise ValueError("Empty key")
        key = key_raw.lower()

        # Process value part (remove surrounding quotes)
        val = value_part.strip()
        if val.startswith('"') and val.endswith('"') and len(val) >= 2:
            val = val[1:-1]

        # Finalize previous pending key if exists
        if pending_key is not None:
            if current_section not in raw_sections:
                raw_sections[current_section] = {}
            raw_sections[current_section][pending_key] = pending_value
            pending_key = None
            pending_value = None

        # Start new pending key
        pending_key = key
        pending_value = val

    # Finalize pending key after loop
    if pending_key is not None and current_section is not None:
        if current_section not in raw_sections:
            raw_sections[current_section] = {}
        raw_sections[current_section][pending_key] = pending_value

    # Interpolation resolution
    cache: Dict[tuple[str, str], str] = {}
    visiting: set[tuple[str, str]] = set()

    def substitute(value: str, section_name: str) -> str:
        """Replace ${...} placeholders in value that are outside quotes."""
        # simple scan, assuming no quotes inside value after quote removal
        # (spec says quoted values keep everything inside quotes exactly,
        # so we do not substitute inside quotes; but we have already removed
        # outer quotes, so remaining value should not contain quotes that delimit)
        i = 0
        n = len(value)
        out = []
        while i < n:
            if value[i] == '$' and i + 1 < n and value[i+1] == '{':
                j = i + 2
                while j < n and value[j] != '}':
                    j += 1
                if j == n:
                    raise KeyError(f"Unterminated placeholder at position {i}")
                content = value[i+2:j]
                i = j + 1
                # parse content
                if ':' in content:
                    # ${section:key}
                    parts = content.split(':', 1)
                    ref_section = parts[0]
                    ref_key = parts[1]
                else:
                    # ${key}
                    ref_section = None
                    ref_key = content

                try:
                    if ref_section is None:
                        # same section: look in own dict first, then DEFAULT
                        if ref_key in raw_sections[section_name]:
                            res_section = section_name
                            res_key = ref_key
                        elif "DEFAULT" in raw_sections and ref_key in raw_sections["DEFAULT"]:
                            res_section = "DEFAULT"
                            res_key = ref_key
                        else:
                            raise KeyError(f"Key {ref_key} not found in section {section_name} or DEFAULT")
                    else:
                        # other section: must exist and key must be in that section
                        if ref_section not in raw_sections:
                            raise KeyError(f"Section {ref_section} not defined")
                        if ref_key in raw_sections[ref_section]:
                            res_section = ref_section
                            res_key = ref_key
                        else:
                            raise KeyError(f"Key {ref_key} not found in section {ref_section}")
                except KeyError as e:
                    raise KeyError(str(e))
                # resolve recursively
                try:
                    resolved_ref = resolve_key(res_section, res_key)
                except ValueError as ve:
                    raise ValueError(str(ve))
                except KeyError as ke:
                    raise ke
                out.append(resolved_ref)
            else:
                out.append(value[i])
                i += 1
        return ''.join(out)

    def resolve_key(section_name: str, key: str) -> str:
        key_pair = (section_name, key)
        if key_pair in cache:
            return cache[key_pair]
        if key_pair in visiting:
            raise ValueError("Interpolation cycle detected")
        visiting.add(key_pair)
        raw_val = raw_sections[section_name][key]
        resolved_val = substitute(raw_val, section_name)
        visiting.remove(key_pair)
        cache[key_pair] = resolved_val
        return resolved_val

    # Resolve all keys (including DEFAULT)
    for sec, sec_dict in raw_sections.items():
        for key in sec_dict:
            try:
                resolve_key(sec, key)
            except (KeyError, ValueError) as e:
                # re-raise to propagate
                raise e

    # Build output excluding DEFAULT
    output: Dict[str, Dict[str, str]] = {}
    for sec, sec_dict in raw_sections.items():
        if sec == "DEFAULT":
            continue
        out_dict = {}
        for key in sec_dict:
            # retrieve resolved value from cache
            resolved = cache[(sec, key)]
            out_dict[key] = resolved
        output[sec] = out_dict

    return output
```
Coding: TTL LRU (hard) Worked
```python
import collections

class TTLCache:
    def __init__(self, capacity: int, ttl: float, clock):
        self.capacity = capacity
        self.ttl = ttl
        self._clock = clock
        self._order = collections.OrderedDict()   # keys in recency order, least recent first
        self._info = {}                          # key -> (value, expiry)
        self._hits = 0
        self._misses = 0
        self._evictions = 0
        self._expirations = 0

    def _now(self):
        return self._clock()

    def _purge_expired(self):
        """Remove all expired entries and count each removal as an expiration."""
        now = self._now()
        to_remove = []
        for key in self._order:
            _, expiry = self._info[key]
            if now >= expiry:
                to_remove.append(key)
        for key in to_remove:
            del self._info[key]
            self._order.pop(key, None)
            self._expirations += 1

    def _remove_single_expired(self, key):
        """Remove an expired entry (the key is known to be expired) and count it."""
        self._expirations += 1
        _, expiry = self._info[key]
        del self._info[key]
        self._order.pop(key, None)

    def put(self, key, value):
        if self.capacity == 0:
            return
        now = self._now()
        if key in self._info:
            # replacement: refresh expiry and mark most recently used
            self._info[key] = (value, now + self.ttl)
            self._order.move_to_end(key)
        else:
            # new key: first purge all expired entries
            self._purge_expired()
            # if cache is full (non‑expired entries == capacity), evict LRU
            if len(self._info) >= self.capacity:
                lru_key, _ = self._order.popitem(last=False)
                del self._info[lru_key]
                self._evictions += 1
            # insert the new entry
            self._info[key] = (value, now + self.ttl)
            self._order[key] = None

    def get(self, key, default=None):
        if self.capacity == 0:
            self._misses += 1
            return default
        now = self._now()
        if key not in self._info:
            self._misses += 1
            return default
        value, expiry = self._info[key]
        if now >= expiry:
            # expired entry: remove it (counts as expiration) and this get is a miss
            self._remove_single_expired(key)
            self._misses += 1
            return default
        else:
            # non‑expired: move to most recent and count as hit
            self._order.move_to_end(key)
            self._hits += 1
            return value

    def __len__(self):
        if self.capacity == 0:
            return 0
        now = self._now()
        cnt = 0
        for _, expiry in self._info.values():
            if now < expiry:
                cnt += 1
        return cnt

    def stats(self):
        return {
            "hits": self._hits,
            "misses": self._misses,
            "evictions": self._evictions,
            "expirations": self._expirations
        }
```
Coding: Parse duration Worked
```python
def parse_duration(s: str) -> int:
    if not s.strip():
        raise ValueError()
    n = len(s)
    i = 0
    last_unit_num = None
    used_units = set()
    parts = []
    while i < n:
        # Skip whitespace
        while i < n and s[i].isspace():
            i += 1
        if i == n:
            break
        if s[i] in '0123456789':
            j = i
            while j < n and s[j] in '0123456789':
                j += 1
            num_str = s[i:j]
            val = int(num_str)
            if j == n:
                # Number without unit
                raise ValueError()
            ch = s[j]
            unit_lower = ch.lower()
            if unit_lower not in ('h', 'm', 's'):
                raise ValueError()
            if unit_lower in used_units:
                raise ValueError()
            unit_num = {'h': 0, 'm': 1, 's': 2}[unit_lower]
            if last_unit_num is not None and unit_num < last_unit_num:
                raise ValueError()
            parts.append((val, unit_num))
            used_units.add(unit_lower)
            last_unit_num = unit_num
            i = j + 1
        else:
            raise ValueError()
    total = 0
    for val, unit_num in parts:
        if unit_num == 0:
            total += val * 3600
        elif unit_num == 1:
            total += val * 60
        else:
            total += val
    return total
```
Coding: Summarize ranges Worked
```python
def summarize_ranges(nums: list[int]) -> str:
    uniq = sorted(set(nums))
    parts = []
    i = 0
    n = len(uniq)
    while i < n:
        j = i
        while j + 1 < n and uniq[j + 1] == uniq[j] + 1:
            j += 1
        if j - i + 1 >= 3:
            parts.append(f"{uniq[i]}..{uniq[j]}")
        else:
            for k in range(i, j + 1):
                parts.append(str(uniq[k]))
        i = j + 1
    return ",".join(parts)
```
Coding: Top customers Worked
```python
def top_customers(rows: list[dict], n: int) -> list[tuple[str, float]]:
    customers = {}  # normalized -> (representative_name, running_total)
    for row in rows:
        cust = row.get("customer", "").strip()
        if not cust:
            continue
        amt_str = row.get("amount", "").strip()
        clean_amt = amt_str.replace('$', '').replace(',', '')
        try:
            amount = float(clean_amt)
        except ValueError:
            continue
        
        norm = cust.casefold()
        if norm in customers:
            name, running = customers[norm]
            customers[norm] = (name, running + amount)
        else:
            customers[norm] = (cust, amount)
    
    # Round totals
    results = []
    for norm, (name, running_total) in customers.items():
        total = round(running_total, 2)
        results.append((name, total))
    
    # Sort by total descending, then name ascending
    results.sort(key=lambda x: (-x[1], x[0]))
    
    return results[:n]
```
Coding: Slugify Worked
```python
import unicodedata

def slugify(title: str, max_len: int = 50) -> str:
    # Step 1: transliterate accented characters to ASCII, drop other non-ASCII
    normalized = unicodedata.normalize('NFKD', title)
    ascii_str = ''.join(ch for ch in normalized if ord(ch) < 128)
    
    # Step 2: lowercase
    lowered = ascii_str.lower()
    
    # Step 3: extract words (a-z and 0-9) and join with hyphens
    words = []
    cur = []
    for ch in lowered:
        if ('a' <= ch <= 'z') or ('0' <= ch <= '9'):
            cur.append(ch)
        else:
            if cur:
                words.append(''.join(cur))
                cur = []
    if cur:
        words.append(''.join(cur))
    slug = '-'.join(words)
    
    # Already within limit?
    if len(slug) <= max_len:
        return slug
    
    # Step 4: truncate without cutting words
    words_slug = slug.split('-')
    first_len = len(words_slug[0])
    if first_len > max_len:
        # hard‑cut the first word
        return words_slug[0][:max_len]
    
    accumulated = []
    current_len = 0
    for w in words_slug:
        if not accumulated:
            accumulated.append(w)
            current_len = len(w)
        else:
            if current_len + 1 + len(w) <= max_len:
                accumulated.append(w)
                current_len += 1 + len(w)
            else:
                break
    return '-'.join(accumulated)
```
Coding: Token bucket Worked
```python
class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float, clock):
        self._capacity = capacity
        self._refill_per_sec = refill_per_sec
        self._clock = clock
        self._tokens = float(capacity)
        self._last_time = self._clock()
    
    def allow(self, cost: float = 1.0) -> bool:
        now = self._clock()
        elapsed = now - self._last_time
        self._tokens = min(self._capacity, self._tokens + self._refill_per_sec * elapsed)
        self._last_time = now
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False
    
    @property
    def tokens(self) -> float:
        now = self._clock()
        elapsed = now - self._last_time
        self._tokens = min(self._capacity, self._tokens + self._refill_per_sec * elapsed)
        self._last_time = now
        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) 50% right
{
  "tool": {
    "web_search": 0.01,
    "calculator": 0.03,
    "calendar": 0.9,
    "email": 0.01,
    "none": 0.05
  },
  "confirm": {
    "true": 0.1,
    "false": 0.9
  }
}
Decisions: Legit security alert (hard) 100% right
{
  "phishing": { "true": 0.02, "false": 0.98 },
  "action_needed": { "true": 0.05, "false": 0.95 }
}
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.2,
    "technical": 0.6,
    "account": 0.1,
    "shipping": 0.05,
    "sales": 0.05
  },
  "urgency": {
    "0": 0.02,
    "1": 0.03,
    "2": 0.05,
    "3": 0.9
  },
  "outage": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Refund wrong plan 100% right
{
  "department": {
    "billing": 1.0,
    "technical": 0.0,
    "account": 0.0,
    "shipping": 0.0,
    "sales": 0.0
  },
  "refund": {
    "true": 1.0,
    "false": 0.0
  },
  "tone": {
    "calm": 1.0,
    "frustrated": 0.0
  }
}
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": 1.0,
    "false": 0.0
  },
  "risk": {
    "0": 0.0,
    "1": 0.0,
    "2": 0.0,
    "3": 1.0
  }
}
Decisions: Pii ssn email 100% right
{
  "data_kind": {
    "none": 0.0,
    "contact": 0.0,
    "financial": 0.0,
    "government_id": 1.0,
    "health": 0.0
  },
  "sensitive": {
    "true": 1.0,
    "false": 0.0
  }
}
Decisions: Review mixed 100% right
{"sentiment": {"positive": 0.05, "neutral": 0.1, "negative": 0.85}, "defect": {"true": 0.95, "false": 0.05}, "recommend": {"true": 0.05, "false": 0.95}}
Documents: Saas escalator (hard) 70% right
{
  "year2_price_per_seat_month": 47.25,
  "year3_price_per_seat_month": 47.25,
  "year1_invoice": 58200.00,
  "year2_invoice": 61236.00,
  "addon_months_billed": 6,
  "addon_invoice": 67473.00,
  "year3_invoice": 134946.00,
  "year3_discount_percent": 15.00,
  "total_contract_value": 321855.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 92% right
{
  "tenants": ["Marcus Lin", "Sofia Lin"],
  "landlord": "Ridgeline Property Group LLC",
  "zip": "97205",
  "lease_end": "2025-11-30",
  "original_monthly_rent": 2150,
  "monthly_rent_from_2025_06_01": 2236,
  "late_fee_from_2025_06_01": 111.8,
  "security_deposit": 2150,
  "total_pet_deposits": 800,
  "total_monthly_payment_july_2025": 2306,
  "move_in_payment": 5750
}
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 0% right

Size: 8.8B parameters. First tested OCT 10.

Models that scored about the same

Comments

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