Google Merchant Center 2027 Image Requirements: Audit Your Feed
Rewarx practical lab · November 3, 2026 · Synthetic fixtures
Google Merchant Center 2027 Image Requirements: Audit Your Feed
Google announces a minimum of 500×500 pixels for all products from January 31, 2027, and recommends 1500×1500 or larger. The documented ceilings are 64 megapixels and 16 MB per file. These are different checks: a 500×500 asset can meet the announced minimum without reaching the recommendation. Check the current official image_link specification.
Use the read-only program below to turn a CSV image list into a dimensions, format, byte-size and readiness report. It was executed against synthetic files on November 3, 2026. It does not certify Merchant Center approval, inspect product accuracy, or change your feed.
Actual synthetic fixture results
| Fixture | Pixels | Bytes | 500px ready | 1500px recommended | Classification |
|---|---|---|---|---|---|
| synthetic-399x600.png | 399 × 600 | 2043 | False | False | BELOW_500 |
| synthetic-499x499.png | 499 × 499 | 1839 | False | False | BELOW_500 |
| synthetic-500x500.png | 500 × 500 | 1842 | True | False | No technical issue found |
| synthetic-1500x1500.png | 1500 × 1500 | 10027 | True | True | No technical issue found |
| synthetic-corrupt.png | × | 25 | False | False | READ_OR_DECODE_ERROR |
| synthetic HTTPS 404 transport fixture | × | False | False | HTTP_404 |
Four files were generated with Pillow, one deliberately damaged file was read, and the HTTP 404 path was tested with a controlled transport fixture. This is six scenarios, not six merchant images or a production sample. The 404 response was simulated locally; no public hosting outage is inferred.
Run the preflight
Use Python 3.9+ and Pillow. Save the complete program as merchant_image_preflight.py. A manifest has an image_link header and one path or public HTTPS URL per row. Local paths are resolved relative to the CSV. Choose a separate output file.
python -m pip install Pillow
python merchant_image_preflight.py images.csv --output image-report.csvComplete image preflight program
"""Read-only image manifest audit. Python 3.9+; pip install Pillow.
CSV requires image_link (public HTTPS URL or local path relative to CSV).
Run: python merchant_image_preflight.py manifest.csv --output image-report.csv
Does not test policy compliance or upload anything.
"""
import argparse,csv,http.client,io,ipaddress,socket,ssl,time
from pathlib import Path
from urllib.parse import urlsplit
from PIL import Image,UnidentifiedImageError
MAX_BYTES=16_000_000
MAX_PIXELS=64_000_000
ALLOWED={'JPEG','PNG','WEBP','GIF','BMP','TIFF'}
class AuditError(Exception):pass
def public_target(url):
u=urlsplit(url)
if u.scheme!='https' or not u.hostname or u.username or u.password or u.port not in (None,443):
raise AuditError('URL_NOT_PUBLIC_HTTPS')
try:ips=list(dict.fromkeys(x[4][0] for x in socket.getaddrinfo(u.hostname,443,type=socket.SOCK_STREAM)))
except socket.gaierror:raise AuditError('DNS_ERROR')
if not ips or any(not ipaddress.ip_address(x).is_global for x in ips):raise AuditError('NONPUBLIC_ADDRESS')
return u,ips[0]
def fetch_public(url):
u,ip=public_target(url)
# Pin the validated address while retaining TLS hostname verification.
conn=http.client.HTTPSConnection(u.hostname,443,timeout=8)
started=time.monotonic()
try:
raw=socket.create_connection((ip,443),timeout=8)
try:conn.sock=ssl.create_default_context().wrap_socket(raw,server_hostname=u.hostname)
except Exception:raw.close();raise
path=u.path or '/'
if u.query:path+='?'+u.query
conn.request('GET',path,headers={'User-Agent':'ReadOnlyImagePreflight/1.0','Accept':'image/*'})
resp=conn.getresponse()
if resp.status!=200:raise AuditError('HTTP_'+str(resp.status))
# Redirects are deliberately not followed.
if int(resp.getheader('Content-Length') or 0)>MAX_BYTES:raise AuditError('FILE_TOO_LARGE')
data=bytearray()
while True:
if time.monotonic()-started>25:raise AuditError('TIME_BUDGET')
chunk=resp.read(min(65536,MAX_BYTES+1-len(data)))
if not chunk:break
data.extend(chunk)
if len(data)>MAX_BYTES:raise AuditError('FILE_TOO_LARGE')
return bytes(data)
finally:conn.close()
def inspect_bytes(data):
if len(data)>MAX_BYTES:raise AuditError('FILE_TOO_LARGE')
with Image.open(io.BytesIO(data)) as im:
w,h=im.size;fmt=im.format
if w*h>MAX_PIXELS:raise AuditError('PIXELS_TOO_LARGE')
im.verify()
# Verify pixel decoding too, not only the container header.
with Image.open(io.BytesIO(data)) as im:im.load()
return {'width':w,'height':h,'format':fmt,'bytes':len(data),
'ready_500':w>=500 and h>=500 and fmt in ALLOWED,
'recommended_1500':w>=1500 and h>=1500,
'error':('UNSUPPORTED_FORMAT' if fmt not in ALLOWED else ('BELOW_500' if w<500 or h<500 else ''))}
def audit(source,base):
out={'source':source,'width':'','height':'','format':'','bytes':'','ready_500':False,'recommended_1500':False,'error':''}
try:
if source.startswith(('http:','https:')):data=fetch_public(source)
else:
if '://' in source:raise AuditError('INVALID_SOURCE_SCHEME')
path=(Path(base)/source).resolve()
if path.stat().st_size>MAX_BYTES:raise AuditError('FILE_TOO_LARGE')
data=path.read_bytes()
out['bytes']=len(data)
out.update(inspect_bytes(data))
except AuditError as e:out['error']=str(e)
except FileNotFoundError:out['error']='FILE_NOT_FOUND'
except (UnidentifiedImageError,OSError,ValueError,SyntaxError) as e:out['error']='READ_OR_DECODE_ERROR'
except Exception as e:out['error']='FETCH_ERROR_'+type(e).__name__
return out
def main():
p=argparse.ArgumentParser();p.add_argument('manifest');p.add_argument('--output',required=True);a=p.parse_args()
src=Path(a.manifest);dest=Path(a.output)
if dest.resolve()==src.resolve():raise SystemExit('Output must not replace input')
with src.open(newline='',encoding='utf-8-sig') as f:
reader=csv.DictReader(f)
if 'image_link' not in (reader.fieldnames or []):raise SystemExit('image_link header required')
rows=[audit(r['image_link'].strip(),src.parent) for r in reader]
with dest.open('w',newline='',encoding='utf-8') as f:
writer=csv.DictWriter(f,fieldnames=['source','width','height','format','bytes','ready_500','recommended_1500','error']);writer.writeheader();writer.writerows(rows)
print(f'{len(rows)} rows audited; {sum(r["ready_500"] for r in rows)} meet this technical 500px check')
if __name__=='__main__':main()
Copyable synthetic input generator
import csv
from pathlib import Path
from PIL import Image
root = Path("synthetic-image-fixtures")
root.mkdir(exist_ok=True)
names = []
for w, h in [(399,600), (499,499), (500,500), (1500,1500)]:
name = f"synthetic-{w}x{h}.png"
Image.new("RGB", (w,h), (30,110,85)).save(root/name)
names.append(name)
(root/"synthetic-corrupt.png").write_bytes(b"synthetic corrupt fixture")
names.append("synthetic-corrupt.png")
with (root/"images.csv").open("w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["image_link"])
writer.writerows([[name] for name in names])
python merchant_image_preflight.py synthetic-image-fixtures/images.csv --output image-report.csvActual local-file CLI output
source,width,height,format,bytes,ready_500,recommended_1500,error
synthetic-399x600.png,399,600,PNG,2043,False,False,BELOW_500
synthetic-499x499.png,499,499,PNG,1839,False,False,BELOW_500
synthetic-500x500.png,500,500,PNG,1842,True,False,
synthetic-1500x1500.png,1500,1500,PNG,10027,True,True,
synthetic-corrupt.png,,,,25,False,False,READ_OR_DECODE_ERROR
How remote scanning is bounded
The program accepts public HTTPS on port 443, resolves and rejects non-public addresses, and connects to the validated address with TLS hostname verification. It does not follow redirects. Downloads have a 16,000,000-byte cap, socket timeouts and a read-time budget. DNS resolution still depends on the operating system resolver. Slow or redirected images are review findings, not proof that Google cannot fetch them.
Only use source URLs you are authorized to fetch. The program performs GET requests and never uploads files, signs in or edits a merchant account. It checks one image at a time. Animated files are evaluated on the initial image; color, overlays, alpha correctness and every animation frame are outside scope.
Interpret the report before fixing assets
- BELOW_500 means one dimension is below the announced minimum; a 399×600 portrait is not ready just because its longest side exceeds 500.
- The 1500px flag is a recommendation marker, not an extra minimum requirement.
- READ_OR_DECODE_ERROR means the file could not be parsed and decoded; retain the original before investigating.
- HTTP_404 identifies the returned response status; other network failures have separate classifications.
- A technically ready file can still be misleading, visually poor or disallowed. Review product truth and channel policy separately.
Reproduce the 15-test suite
Save both preflight programs above from their respective guides, plus this test file, in one directory. Run python test_practical_preflights_20261103.py. The suite uses temporary local files and a controlled HTTP response for the 404 case. It sends no real upload or paid request.
Complete unit test source
import csv,io,json,tempfile,unittest,sys
from pathlib import Path
from unittest.mock import patch
from urllib.error import HTTPError
from PIL import Image
sys.path.insert(0,str(Path('assets/repro').resolve()))
import merchant_image_preflight as imageaudit
import shopify_variant_csv_preflight as variants
def png(w,h):
b=io.BytesIO();Image.new('RGB',(w,h),'teal').save(b,format='PNG');return b.getvalue()
class ImageTests(unittest.TestCase):
def test_dimension_boundaries(self):
for w,h,ready,rec in [(399,600,False,False),(499,499,False,False),(500,500,True,False),(1500,1500,True,True)]:
with self.subTest(size=(w,h)):
r=imageaudit.inspect_bytes(png(w,h));self.assertEqual(r['ready_500'],ready);self.assertEqual(r['recommended_1500'],rec)
def test_corrupt(self):
with tempfile.TemporaryDirectory() as d:
p=Path(d)/'bad.png';p.write_bytes(b'not an image');self.assertEqual(imageaudit.audit('bad.png',d)['error'],'READ_OR_DECODE_ERROR')
def test_404_adapter(self):
with patch.object(imageaudit,'fetch_public',side_effect=imageaudit.AuditError('HTTP_404')):
self.assertEqual(imageaudit.audit('https://example.com/missing.png','.')['error'],'HTTP_404')
def test_local_network_blocked(self):
with self.assertRaises(imageaudit.AuditError):imageaudit.public_target('https://127.0.0.1/a')
def test_scheme_blocked(self):
with self.assertRaises(imageaudit.AuditError):imageaudit.public_target('http://example.com/a')
def test_large_bytes(self):
with self.assertRaises(imageaudit.AuditError):imageaudit.inspect_bytes(b'0'*(imageaudit.MAX_BYTES+1))
def test_http404_transport(self):
# The fetch function itself receives a simulated HTTP response; no internet server required.
class Sock:
def close(self):pass
class Resp:
status=404
class Conn:
def __init__(self,*a,**k):pass
def request(self,*a,**k):pass
def getresponse(self):return Resp()
def close(self):pass
with patch.object(imageaudit,'public_target',return_value=(imageaudit.urlsplit('https://example.com/missing'), '93.184.216.34')),patch.object(imageaudit.http.client,'HTTPSConnection',Conn),patch.object(imageaudit.socket,'create_connection',return_value=Sock()),patch.object(imageaudit.ssl,'create_default_context') as ctx:
ctx.return_value.wrap_socket.return_value=Sock()
with self.assertRaisesRegex(imageaudit.AuditError,'HTTP_404'):imageaudit.fetch_public('https://example.com/missing')
class CSVTests(unittest.TestCase):
def scan(self,rows,headers=None):
headers=headers or ['URL handle','Title','SKU','Option1 name','Option1 value','Product image URL','Variant image URL']
with tempfile.TemporaryDirectory() as d:
p=Path(d)/'products.csv'
with p.open('w',newline='') as f:w=csv.writer(f);w.writerow(headers);w.writerows(rows)
original=p.read_bytes();result=variants.validate(p);self.assertEqual(p.read_bytes(),original);return result
def test_quoted_comma_and_shared_image(self):
u='https://example.com/red.png';r=self.scan([['shirt','Shirt, cotton','S','Color','Red',u,u],['shirt','','M','Color','Blue',u,u]])
self.assertEqual(r,[])
def test_legacy(self):
self.assertEqual(self.scan([['shirt','Shirt','S','Color','Red','','https://example.com/r.png']],['Handle','Title','Variant SKU','Option1 Name','Option1 Value','Image Src','Variant Image']),[])
def test_duplicate_key(self):
row=['shirt','Shirt','S','Color','Red','','https://example.com/r.png'];r=self.scan([row,row]);self.assertIn('DUPLICATE_VARIANT_KEY',[x['code'] for x in r])
def test_blank_and_invalid(self):
r=self.scan([['shirt','Shirt','S','Color','Red','',''],['shoe','Shoe','X','Color','Black','','file:///a']]);codes={x['code'] for x in r};self.assertTrue({'BLANK_IMAGE_REVIEW','INVALID_URL'}<=codes)
def test_crossproduct(self):
r=self.scan([['a','A','A','Color','Red','https://example.com/a.png','https://example.com/b.png'],['b','B','B','Color','Blue','https://example.com/b.png','https://example.com/b.png']]);self.assertIn('CROSS_PRODUCT_IMAGE_REVIEW',[x['code'] for x in r])
def test_gallery_row(self):
self.assertEqual(self.scan([['a','','','','','https://example.com/a.png','']]),[])
def test_option_dependency(self):
r=self.scan([['a','A','A','','','','https://example.com/a.png']]);self.assertIn('OPTION_DEPENDENCY',[x['code'] for x in r])
def test_multiline_line_numbers(self):
r=self.scan([['a','A\nlong','A','Color','Red','','https://example.com/a.png'],['a','','B','Color','Blue','','']]);self.assertEqual(r[0]['line'],4)
if __name__=='__main__':unittest.main(verbosity=2)
Actual test log
test_blank_and_invalid (__main__.CSVTests) ... ok
test_crossproduct (__main__.CSVTests) ... ok
test_duplicate_key (__main__.CSVTests) ... ok
test_gallery_row (__main__.CSVTests) ... ok
test_legacy (__main__.CSVTests) ... ok
test_multiline_line_numbers (__main__.CSVTests) ... ok
test_option_dependency (__main__.CSVTests) ... ok
test_quoted_comma_and_shared_image (__main__.CSVTests) ... ok
test_404_adapter (__main__.ImageTests) ... ok
test_corrupt (__main__.ImageTests) ... ok
test_dimension_boundaries (__main__.ImageTests) ... ok
test_http404_transport (__main__.ImageTests) ... ok
test_large_bytes (__main__.ImageTests) ... ok
test_local_network_blocked (__main__.ImageTests) ... ok
test_scheme_blocked (__main__.ImageTests) ... ok
----------------------------------------------------------------------
Ran 15 tests in 0.046s
OK
Related practical guides
- Validate Shopify variant CSV mappings
- Test XMP preservation while resizing
- Product image disclaimer limits
- Artifact diagnosis
Sources checked November 3, 2026
Rewarx workflow entry
Inspect the Rewarx background-removal entry page. Its primary button leads to Studio authentication. The validators and metadata lab here run locally; this article does not claim the Studio performs these checks or preserves metadata automatically.
Check HTTP delivery before file specifications
An image URL may return HTML, a permission error or a redirect instead of the asset. Use the read-only image HTTP diagnostic and local server fixtures to record the response, then return to this guide for dimensions and file checks.